diff --git a/v2/pkg/engine/datasource/grpc_datasource/compiler.go b/v2/pkg/engine/datasource/grpc_datasource/compiler.go index 1b519b7e31..6192c2ea0c 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/compiler.go +++ b/v2/pkg/engine/datasource/grpc_datasource/compiler.go @@ -323,7 +323,14 @@ func (p *RPCCompiler) Compile(executionPlan *RPCExecutionPlan, inputData gjson.R for _, call := range executionPlan.Calls { inputMessage := p.doc.MessageByName(call.Request.Name) + if inputMessage.Name == "" { + return nil, fmt.Errorf("input message %s not found in document", call.Request.Name) + } + outputMessage := p.doc.MessageByName(call.Response.Name) + if outputMessage.Name == "" { + return nil, fmt.Errorf("output message %s not found in document", call.Response.Name) + } request := p.buildProtoMessage(inputMessage, &call.Request, inputData) response := p.newEmptyMessage(outputMessage) diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go index 1eb2b6ca56..0d8e417ce5 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan.go @@ -166,6 +166,15 @@ type RPCField struct { Message *RPCMessage } +// AliasOrPath returns the alias of the field if it exists, otherwise it returns the JSONPath. +func (r *RPCField) AliasOrPath() string { + if r.Alias != "" { + return r.Alias + } + + return r.JSONPath +} + // RPCFields is a list of RPCFields that provides helper methods // for working with collections of fields. type RPCFields []RPCField diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_test.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_test.go index 5c5f066aba..bbe93fc88d 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_test.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_test.go @@ -2510,3 +2510,611 @@ func TestProductExecutionPlan(t *testing.T) { }) } } + +func TestProductExecutionPlanWithAliases(t *testing.T) { + tests := []struct { + name string + query string + expectedPlan *RPCExecutionPlan + expectedError string + }{ + { + name: "Should create an execution plan for a query with an alias on the users root field", + query: "query { foo: users { id name } }", + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryUsers", + Request: RPCMessage{ + Name: "QueryUsersRequest", + }, + Response: RPCMessage{ + Name: "QueryUsersResponse", + Fields: RPCFields{ + { + Name: "users", + TypeName: string(DataTypeMessage), + JSONPath: "users", + Alias: "foo", + Repeated: true, + Message: &RPCMessage{ + Name: "User", + Fields: RPCFields{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a query with an alias on a field with arguments", + query: `query { specificUser: user(id: "123") { userId: id userName: name } }`, + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryUser", + Request: RPCMessage{ + Name: "QueryUserRequest", + Fields: []RPCField{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + }, + }, + }, + Response: RPCMessage{ + Name: "QueryUserResponse", + Fields: RPCFields{ + { + Name: "user", + TypeName: string(DataTypeMessage), + JSONPath: "user", + Alias: "specificUser", + Message: &RPCMessage{ + Name: "User", + Fields: RPCFields{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + Alias: "userId", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + Alias: "userName", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a query with multiple aliases on the same level", + query: "query { allUsers: users { id name } allCategories: categories { id name categoryType: kind } }", + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryUsers", + Request: RPCMessage{ + Name: "QueryUsersRequest", + }, + Response: RPCMessage{ + Name: "QueryUsersResponse", + Fields: RPCFields{ + { + Name: "users", + TypeName: string(DataTypeMessage), + JSONPath: "users", + Alias: "allUsers", + Repeated: true, + Message: &RPCMessage{ + Name: "User", + Fields: RPCFields{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + }, + }, + }, + }, + }, + }, + }, + { + ServiceName: "Products", + MethodName: "QueryCategories", + CallID: 1, + Request: RPCMessage{ + Name: "QueryCategoriesRequest", + }, + Response: RPCMessage{ + Name: "QueryCategoriesResponse", + Fields: RPCFields{ + { + Name: "categories", + TypeName: string(DataTypeMessage), + JSONPath: "categories", + Alias: "allCategories", + Repeated: true, + Message: &RPCMessage{ + Name: "Category", + Fields: RPCFields{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + }, + { + Name: "kind", + TypeName: string(DataTypeEnum), + JSONPath: "kind", + Alias: "categoryType", + EnumName: "CategoryKind", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a query with aliases on nested object fields", + query: "query { nestedData: nestedType { identifier: id title: name childB: b { identifier: id title: name grandChild: c { identifier: id title: name } } } }", + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryNestedType", + Request: RPCMessage{ + Name: "QueryNestedTypeRequest", + }, + Response: RPCMessage{ + Name: "QueryNestedTypeResponse", + Fields: RPCFields{ + { + Name: "nested_type", + TypeName: string(DataTypeMessage), + JSONPath: "nestedType", + Alias: "nestedData", + Repeated: true, + Message: &RPCMessage{ + Name: "NestedTypeA", + Fields: RPCFields{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + Alias: "identifier", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + Alias: "title", + }, + { + Name: "b", + TypeName: string(DataTypeMessage), + JSONPath: "b", + Alias: "childB", + Message: &RPCMessage{ + Name: "NestedTypeB", + Fields: RPCFields{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + Alias: "identifier", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + Alias: "title", + }, + { + Name: "c", + TypeName: string(DataTypeMessage), + JSONPath: "c", + Alias: "grandChild", + Message: &RPCMessage{ + Name: "NestedTypeC", + Fields: RPCFields{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + Alias: "identifier", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + Alias: "title", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a query with aliases on interface fields", + query: "query { pet: randomPet { identifier: id petName: name animalKind: kind ... on Cat { volumeLevel: meowVolume } ... on Dog { volumeLevel: barkVolume } } }", + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryRandomPet", + Request: RPCMessage{ + Name: "QueryRandomPetRequest", + }, + Response: RPCMessage{ + Name: "QueryRandomPetResponse", + Fields: RPCFields{ + { + Name: "random_pet", + TypeName: string(DataTypeMessage), + JSONPath: "randomPet", + Alias: "pet", + Message: &RPCMessage{ + Name: "Animal", + OneOfType: OneOfTypeInterface, + MemberTypes: []string{ + "Cat", + "Dog", + }, + FieldSelectionSet: RPCFieldSelectionSet{ + "Cat": { + { + Name: "meow_volume", + TypeName: string(DataTypeInt32), + JSONPath: "meowVolume", + Alias: "volumeLevel", + }, + }, + "Dog": { + { + Name: "bark_volume", + TypeName: string(DataTypeInt32), + JSONPath: "barkVolume", + Alias: "volumeLevel", + }, + }, + }, + Fields: RPCFields{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + Alias: "identifier", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + Alias: "petName", + }, + { + Name: "kind", + TypeName: string(DataTypeString), + JSONPath: "kind", + Alias: "animalKind", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a query with aliases on union type fields", + query: "query { searchResults: randomSearchResult { ... on Product { productId: id productName: name cost: price } ... on User { userId: id userName: name } ... on Category { categoryId: id categoryName: name categoryType: kind } } }", + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryRandomSearchResult", + Request: RPCMessage{ + Name: "QueryRandomSearchResultRequest", + }, + Response: RPCMessage{ + Name: "QueryRandomSearchResultResponse", + Fields: RPCFields{ + { + Name: "random_search_result", + TypeName: string(DataTypeMessage), + JSONPath: "randomSearchResult", + Alias: "searchResults", + Message: &RPCMessage{ + Name: "SearchResult", + OneOfType: OneOfTypeUnion, + MemberTypes: []string{ + "Product", + "User", + "Category", + }, + Fields: RPCFields{}, + FieldSelectionSet: RPCFieldSelectionSet{ + "Product": { + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + Alias: "productId", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + Alias: "productName", + }, + { + Name: "price", + TypeName: string(DataTypeDouble), + JSONPath: "price", + Alias: "cost", + }, + }, + "User": { + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + Alias: "userId", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + Alias: "userName", + }, + }, + "Category": { + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + Alias: "categoryId", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + Alias: "categoryName", + }, + { + Name: "kind", + TypeName: string(DataTypeEnum), + JSONPath: "kind", + Alias: "categoryType", + EnumName: "CategoryKind", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a mutation with aliases", + query: `mutation { newUser: createUser(input: { name: "John Doe" }) { userId: id fullName: name } }`, + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "MutationCreateUser", + Request: RPCMessage{ + Name: "MutationCreateUserRequest", + Fields: []RPCField{ + { + Name: "input", + TypeName: string(DataTypeMessage), + JSONPath: "input", + Message: &RPCMessage{ + Name: "UserInput", + Fields: []RPCField{ + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + }, + }, + }, + }, + }, + }, + Response: RPCMessage{ + Name: "MutationCreateUserResponse", + Fields: RPCFields{ + { + Name: "create_user", + TypeName: string(DataTypeMessage), + JSONPath: "createUser", + Alias: "newUser", + Message: &RPCMessage{ + Name: "User", + Fields: RPCFields{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + Alias: "userId", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + Alias: "fullName", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Should create an execution plan for a query with aliases on field with complex input type", + query: `query { bookCategories: categoriesByKind(kind: BOOK) { identifier: id title: name type: kind } }`, + expectedPlan: &RPCExecutionPlan{ + Calls: []RPCCall{ + { + ServiceName: "Products", + MethodName: "QueryCategoriesByKind", + Request: RPCMessage{ + Name: "QueryCategoriesByKindRequest", + Fields: []RPCField{ + { + Name: "kind", + TypeName: string(DataTypeEnum), + JSONPath: "kind", + EnumName: "CategoryKind", + }, + }, + }, + Response: RPCMessage{ + Name: "QueryCategoriesByKindResponse", + Fields: RPCFields{ + { + Name: "categories_by_kind", + TypeName: string(DataTypeMessage), + JSONPath: "categoriesByKind", + Alias: "bookCategories", + Repeated: true, + Message: &RPCMessage{ + Name: "Category", + Fields: RPCFields{ + { + Name: "id", + TypeName: string(DataTypeString), + JSONPath: "id", + Alias: "identifier", + }, + { + Name: "name", + TypeName: string(DataTypeString), + JSONPath: "name", + Alias: "title", + }, + { + Name: "kind", + TypeName: string(DataTypeEnum), + JSONPath: "kind", + Alias: "type", + EnumName: "CategoryKind", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + report := &operationreport.Report{} + // Parse the GraphQL schema + schemaDoc := grpctest.MustGraphQLSchema(t) + + astvalidation.DefaultDefinitionValidator().Validate(&schemaDoc, report) + if report.HasErrors() { + t.Fatalf("failed to validate schema: %s", report.Error()) + } + + // Parse the GraphQL query + queryDoc, queryReport := astparser.ParseGraphqlDocumentString(tt.query) + if queryReport.HasErrors() { + t.Fatalf("failed to parse query: %s", queryReport.Error()) + } + + astvalidation.DefaultOperationValidator().Validate(&queryDoc, &schemaDoc, report) + if report.HasErrors() { + t.Fatalf("failed to validate query: %s", report.Error()) + } + + planner := NewPlanner("Products", testMapping()) + outPlan, err := planner.PlanOperation(&queryDoc, &schemaDoc) + + if tt.expectedError != "" { + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.expectedError) { + t.Fatalf("expected error to contain %q, got %q", tt.expectedError, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + diff := cmp.Diff(tt.expectedPlan, outPlan) + if diff != "" { + t.Fatalf("execution plan mismatch: %s", diff) + } + }) + } + +} diff --git a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go index f1ad122aef..2caf7eb350 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go +++ b/v2/pkg/engine/datasource/grpc_datasource/execution_plan_visitor.go @@ -333,6 +333,7 @@ func (r *rpcPlanVisitor) EnterField(ref int) { TypeName: typeName.String(), JSONPath: fieldName, Repeated: r.definition.TypeIsList(fdt), + Alias: r.operation.FieldAliasString(ref), } if typeName == DataTypeEnum { diff --git a/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource.go b/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource.go index a87a266247..73531ce9cd 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource.go +++ b/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource.go @@ -163,13 +163,13 @@ func (d *DataSource) marshalResponseJSON(arena *astjson.Arena, message *RPCMessa for _, field := range validFields { if field.StaticValue != "" { if len(message.MemberTypes) == 0 { - root.Set(field.JSONPath, arena.NewString(field.StaticValue)) + root.Set(field.AliasOrPath(), arena.NewString(field.StaticValue)) continue } for _, memberTypes := range message.MemberTypes { if memberTypes == string(data.Type().Descriptor().Name()) { - root.Set(field.JSONPath, arena.NewString(memberTypes)) + root.Set(field.AliasOrPath(), arena.NewString(memberTypes)) break } } @@ -185,12 +185,12 @@ func (d *DataSource) marshalResponseJSON(arena *astjson.Arena, message *RPCMessa if fd.IsList() { list := data.Get(fd).List() if !list.IsValid() { - root.Set(field.JSONPath, arena.NewNull()) + root.Set(field.AliasOrPath(), arena.NewNull()) continue } arr := arena.NewArray() - root.Set(field.JSONPath, arr) + root.Set(field.AliasOrPath(), arr) for i := 0; i < list.Len(); i++ { switch fd.Kind() { @@ -214,7 +214,7 @@ func (d *DataSource) marshalResponseJSON(arena *astjson.Arena, message *RPCMessa if fd.Kind() == protoref.MessageKind { msg := data.Get(fd).Message() if !msg.IsValid() { - root.Set(field.JSONPath, arena.NewNull()) + root.Set(field.AliasOrPath(), arena.NewNull()) continue } @@ -229,13 +229,13 @@ func (d *DataSource) marshalResponseJSON(arena *astjson.Arena, message *RPCMessa return nil, err } } else { - root.Set(field.JSONPath, value) + root.Set(field.AliasOrPath(), value) } continue } - d.setJSONValue(arena, root, field.JSONPath, data, fd) + d.setJSONValue(arena, root, field.AliasOrPath(), data, fd) } return root, nil diff --git a/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_test.go b/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_test.go index 71bf396635..01130a4dde 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_test.go +++ b/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_test.go @@ -1108,7 +1108,6 @@ func Test_DataSource_Load_WithCategoryQueries(t *testing.T) { // Test_DataSource_Load_WithTotalCalculation tests the calculation of order totals using the // MockService implementation func Test_DataSource_Load_WithTotalCalculation(t *testing.T) { - conn, cleanup := setupTestGRPCServer(t) t.Cleanup(cleanup) @@ -1287,3 +1286,254 @@ func Test_DataSource_Load_WithTypename(t *testing.T) { require.NotEmpty(t, user.Name, "User name should not be empty") } } + +// Test_DataSource_Load_WithAliases tests various GraphQL alias scenarios +// with the actual gRPC service using bufconn +func Test_DataSource_Load_WithAliases(t *testing.T) { + conn, cleanup := setupTestGRPCServer(t) + t.Cleanup(cleanup) + + testCases := []struct { + name string + query string + vars string + validate func(t *testing.T, data map[string]interface{}) + }{ + { + name: "Simple root field alias", + query: `query { allUsers: users { id name } }`, + vars: "{}", + validate: func(t *testing.T, data map[string]interface{}) { + users, ok := data["allUsers"].([]interface{}) + require.True(t, ok, "allUsers should be an array") + require.NotEmpty(t, users, "allUsers should not be empty") + + user := users[0].(map[string]interface{}) + require.Contains(t, user, "id") + require.Contains(t, user, "name") + require.NotEmpty(t, user["id"]) + require.NotEmpty(t, user["name"]) + }, + }, + { + name: "Field alias with arguments and nested field aliases", + query: `query { specificUser: user(id: $id) { userId: id userName: name } }`, + vars: `{"variables": {"id": "123"}}`, + validate: func(t *testing.T, data map[string]interface{}) { + user, ok := data["specificUser"].(map[string]interface{}) + require.True(t, ok, "specificUser should be an object") + require.NotEmpty(t, user, "specificUser should not be empty") + + require.Contains(t, user, "userId") + require.Contains(t, user, "userName") + require.Equal(t, "123", user["userId"]) + require.Equal(t, "User 123", user["userName"]) + + // Ensure original field names are not present + require.NotContains(t, user, "id") + require.NotContains(t, user, "name") + }, + }, + { + name: "Multiple aliases on the same level", + query: `query { allUsers: users { id name } allCategories: categories { id name categoryType: kind } }`, + vars: "{}", + validate: func(t *testing.T, data map[string]interface{}) { + // Check users alias + users, ok := data["allUsers"].([]interface{}) + require.True(t, ok, "allUsers should be an array") + require.NotEmpty(t, users, "allUsers should not be empty") + + // Check categories alias + categories, ok := data["allCategories"].([]interface{}) + require.True(t, ok, "allCategories should be an array") + require.NotEmpty(t, categories, "allCategories should not be empty") + + // Check first category has aliased field + category := categories[0].(map[string]interface{}) + require.Contains(t, category, "categoryType") + require.NotContains(t, category, "kind", "original field name should not be present") + }, + }, + { + name: "Nested object aliases", + query: `query { nestedData: nestedType { identifier: id title: name childB: b { identifier: id title: name } } }`, + vars: "{}", + validate: func(t *testing.T, data map[string]interface{}) { + nestedData, ok := data["nestedData"].([]interface{}) + require.True(t, ok, "nestedData should be an array") + require.NotEmpty(t, nestedData, "nestedData should not be empty") + + nestedItem := nestedData[0].(map[string]interface{}) + require.Contains(t, nestedItem, "identifier") + require.Contains(t, nestedItem, "title") + require.Contains(t, nestedItem, "childB") + + // Check nested object aliases + childB := nestedItem["childB"].(map[string]interface{}) + require.Contains(t, childB, "identifier") + require.Contains(t, childB, "title") + + // Ensure original field names are not present + require.NotContains(t, nestedItem, "id") + require.NotContains(t, nestedItem, "name") + require.NotContains(t, nestedItem, "b") + }, + }, + { + name: "Interface aliases", + query: `query { pet: randomPet { identifier: id petName: name animalKind: kind ... on Cat { volumeLevel: meowVolume } ... on Dog { volumeLevel: barkVolume } } }`, + vars: "{}", + validate: func(t *testing.T, data map[string]interface{}) { + pet, ok := data["pet"].(map[string]interface{}) + require.True(t, ok, "pet should be an object") + require.NotEmpty(t, pet, "pet should not be empty") + + require.Contains(t, pet, "identifier") + require.Contains(t, pet, "petName") + require.Contains(t, pet, "animalKind") + + // Check if it has the volume level (either cat or dog) + if _, hasCat := pet["volumeLevel"]; hasCat { + require.Contains(t, pet, "volumeLevel") + require.IsType(t, float64(0), pet["volumeLevel"]) // JSON numbers are float64 + } + + // Ensure original field names are not present + require.NotContains(t, pet, "id") + require.NotContains(t, pet, "name") + require.NotContains(t, pet, "kind") + }, + }, + { + name: "Union type aliases", + query: `query { searchResults: randomSearchResult { ... on Product { productId: id productName: name cost: price } ... on User { userId: id userName: name } ... on Category { categoryId: id categoryName: name categoryType: kind } } }`, + vars: "{}", + validate: func(t *testing.T, data map[string]interface{}) { + searchResults, ok := data["searchResults"].(map[string]interface{}) + require.True(t, ok, "searchResults should be an object") + require.NotEmpty(t, searchResults, "searchResults should not be empty") + + // Check based on which union member was returned + if productId, hasProduct := searchResults["productId"]; hasProduct { + // Product case + require.Contains(t, searchResults, "productName") + require.Contains(t, searchResults, "cost") + require.Equal(t, "product-random-1", productId) + require.Equal(t, "Random Product", searchResults["productName"]) + require.Equal(t, 29.99, searchResults["cost"]) + } else if userId, hasUser := searchResults["userId"]; hasUser { + // User case + require.Contains(t, searchResults, "userName") + require.Equal(t, "user-random-1", userId) + require.Equal(t, "Random User", searchResults["userName"]) + } else if categoryId, hasCategory := searchResults["categoryId"]; hasCategory { + // Category case + require.Contains(t, searchResults, "categoryName") + require.Contains(t, searchResults, "categoryType") + require.Equal(t, "category-random-1", categoryId) + require.Equal(t, "Random Category", searchResults["categoryName"]) + require.Equal(t, "ELECTRONICS", searchResults["categoryType"]) + } else { + t.Fatal("searchResults should contain at least one union member with aliased fields") + } + + // Ensure original field names are not present + require.NotContains(t, searchResults, "id") + require.NotContains(t, searchResults, "name") + require.NotContains(t, searchResults, "price") + require.NotContains(t, searchResults, "kind") + }, + }, + { + name: "Mutation aliases", + query: `mutation { newUser: createUser(input: $input) { userId: id fullName: name } }`, + vars: `{"variables": {"input": {"name": "John Doe"}}}`, + validate: func(t *testing.T, data map[string]interface{}) { + newUser, ok := data["newUser"].(map[string]interface{}) + require.True(t, ok, "newUser should be an object") + require.NotEmpty(t, newUser, "newUser should not be empty") + + require.Contains(t, newUser, "userId") + require.Contains(t, newUser, "fullName") + require.NotEmpty(t, newUser["userId"]) + require.Equal(t, "John Doe", newUser["fullName"]) + + // Ensure original field names are not present + require.NotContains(t, newUser, "id") + require.NotContains(t, newUser, "name") + }, + }, + { + name: "Enum field aliases", + query: `query { bookCategories: categoriesByKind(kind: $kind) { identifier: id title: name type: kind } }`, + vars: `{"variables": {"kind": "BOOK"}}`, + validate: func(t *testing.T, data map[string]interface{}) { + bookCategories, ok := data["bookCategories"].([]interface{}) + require.True(t, ok, "bookCategories should be an array") + require.NotEmpty(t, bookCategories, "bookCategories should not be empty") + + category := bookCategories[0].(map[string]interface{}) + require.Contains(t, category, "identifier") + require.Contains(t, category, "title") + require.Contains(t, category, "type") + require.Equal(t, "BOOK", category["type"]) + + // Ensure original field names are not present + require.NotContains(t, category, "id") + require.NotContains(t, category, "name") + require.NotContains(t, category, "kind") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Parse the GraphQL schema + schemaDoc := grpctest.MustGraphQLSchema(t) + + // Parse the GraphQL query + queryDoc, report := astparser.ParseGraphqlDocumentString(tc.query) + if report.HasErrors() { + t.Fatalf("failed to parse query: %s", report.Error()) + } + + compiler, err := NewProtoCompiler(grpctest.MustProtoSchema(t), testMapping()) + if err != nil { + t.Fatalf("failed to compile proto: %v", err) + } + + // Create the datasource + ds, err := NewDataSource(conn, DataSourceConfig{ + Operation: &queryDoc, + Definition: &schemaDoc, + SubgraphName: "Products", + Mapping: testMapping(), + Compiler: compiler, + }) + require.NoError(t, err) + + // Execute the query through our datasource + output := new(bytes.Buffer) + input := fmt.Sprintf(`{"query":%q,"body":%s}`, tc.query, tc.vars) + err = ds.Load(context.Background(), []byte(input), output) + require.NoError(t, err) + + // Parse the response + var resp struct { + Data map[string]interface{} `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors,omitempty"` + } + + err = json.Unmarshal(output.Bytes(), &resp) + require.NoError(t, err, "Failed to unmarshal response") + require.Empty(t, resp.Errors, "Response should not contain errors") + require.NotEmpty(t, resp.Data, "Response should contain data") + + // Run the validation function + tc.validate(t, resp.Data) + }) + } +} diff --git a/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go b/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go index 10313fe474..ae31951412 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go +++ b/v2/pkg/engine/datasource/grpc_datasource/mapping_test_helper.go @@ -87,9 +87,9 @@ func testMapping() *GRPCMapping { }, MutationRPCs: RPCConfigMap{ "createUser": { - RPC: "CreateUser", - Request: "CreateUserRequest", - Response: "CreateUserResponse", + RPC: "MutationCreateUser", + Request: "MutationCreateUserRequest", + Response: "MutationCreateUserResponse", }, "performAction": { RPC: "MutationPerformAction", diff --git a/v2/pkg/grpctest/mapping/mapping.go b/v2/pkg/grpctest/mapping/mapping.go index 9282fa537e..e0b254603c 100644 --- a/v2/pkg/grpctest/mapping/mapping.go +++ b/v2/pkg/grpctest/mapping/mapping.go @@ -89,9 +89,9 @@ func DefaultGRPCMapping() *grpcdatasource.GRPCMapping { }, MutationRPCs: grpcdatasource.RPCConfigMap{ "createUser": { - RPC: "CreateUser", - Request: "CreateUserRequest", - Response: "CreateUserResponse", + RPC: "MutationCreateUser", + Request: "MutationCreateUserRequest", + Response: "MutationCreateUserResponse", }, "performAction": { RPC: "MutationPerformAction", @@ -469,6 +469,19 @@ func DefaultGRPCMapping() *grpcdatasource.GRPCMapping { TargetName: "payload", }, }, + "SearchResult": { + "product": { + TargetName: "product", + }, + }, + "ActionResult": { + "actionSuccess": { + TargetName: "action_success", + }, + "actionError": { + TargetName: "action_error", + }, + }, }, } }