diff --git a/.gitignore b/.gitignore index a1a2575..4c03823 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ vendor .idea tests/dataloader/generated/ +tests/proto_unwrap/generated/ diff --git a/README.md b/README.md index 1c7d1b7..920be62 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ proto2gql: "methodName": # method name alias: "methodAlias" request_type: "QUERY" # method type in GraphQL Schema (QUERY|MUTATION) + unwrap_response_field: true # In proto we can't use primitive or repeated type in method response. + # If unwrap_response_field = true unpack response gql object with 1 field. messages: # messages settings - "Request$": # message name match regex fields: @@ -364,7 +366,9 @@ Default wait duration 10ms. Full example can be found in [tests](https://github.com/EGT-Ukraine/go2gql/tree/master/tests/dataloader). -## UPGRADE FROM 1.x to 2.0 +## Note to users migrating from older releases + +### Migrating from 1.x to 2.0 ### Swagger plugin @@ -377,7 +381,7 @@ Use `queries_service_name` & `mutations_service_name` instead. Use `queries_service_name` & `mutations_service_name` instead. -## UPGRADE FROM 2.x to 3.0 +## Migrating from 2.x to 3.0 ### Swagger plugin @@ -390,8 +394,6 @@ Use `service_name` instead. Use `service_name` instead. -## Note to users migrating from older releases - ### Migrating from 3.x to 4.0 tracer.Tracer was replaced with opentracing.Tracer diff --git a/generator/plugins/graphql/templates.go b/generator/plugins/graphql/templates.go index c8d202d..26b9732 100644 --- a/generator/plugins/graphql/templates.go +++ b/generator/plugins/graphql/templates.go @@ -109,7 +109,7 @@ func templatesOutput_map_fieldsGohtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "templates/output_map_fields.gohtml", size: 1541, mode: os.FileMode(436), modTime: time.Unix(1546956346, 0)} + info := bindataFileInfo{name: "templates/output_map_fields.gohtml", size: 1541, mode: os.FileMode(436), modTime: time.Unix(1546958010, 0)} a := &asset{bytes: bytes, info: info} return a, nil } diff --git a/generator/plugins/proto2gql/config.go b/generator/plugins/proto2gql/config.go index bc9aa45..7e94a68 100644 --- a/generator/plugins/proto2gql/config.go +++ b/generator/plugins/proto2gql/config.go @@ -22,9 +22,10 @@ type MessageConfig struct { DataLoaders []dataloader.DataLoaderFieldConfig `mapstructure:"data_loaders"` } type MethodConfig struct { - Alias string `mapstructure:"alias"` - RequestType string `mapstructure:"request_type"` // QUERY | MUTATION - DataLoaderProvider dataloader.DataLoaderProviderConfig `mapstructure:"data_loader_provider"` + Alias string `mapstructure:"alias"` + RequestType string `mapstructure:"request_type"` // QUERY | MUTATION + DataLoaderProvider dataloader.DataLoaderProviderConfig `mapstructure:"data_loader_provider"` + UnwrapResponseField bool `mapstructure:"unwrap_response_field"` } type ServiceConfig struct { ServiceName string `mapstructure:"service_name"` diff --git a/generator/plugins/proto2gql/services.go b/generator/plugins/proto2gql/services.go index 26172db..dde0009 100644 --- a/generator/plugins/proto2gql/services.go +++ b/generator/plugins/proto2gql/services.go @@ -148,10 +148,30 @@ func (g Proto2GraphQL) serviceMethod(sc ServiceConfig, cfg MethodConfig, file *p if err != nil { return nil, errors.Wrap(err, "failed to resolve file type file") } - outType, err := g.TypeOutputTypeResolver(outputMsgTypeFile, method.OutputMessage) + + if cfg.UnwrapResponseField && len(method.OutputMessage.Fields) != 1 { + return nil, errors.Errorf("can't unwrap `%s` service `%s` method response. Output message must have 1 field.", method.Service.Name, method.Name) + } + + var outProtoType parser.Type + var outProtoTypeRepeated bool + + if cfg.UnwrapResponseField { + outProtoType = method.OutputMessage.Fields[0].Type + outProtoTypeRepeated = method.OutputMessage.Fields[0].Repeated + } else { + outProtoType = method.OutputMessage + } + + outType, err := g.TypeOutputTypeResolver(outputMsgTypeFile, outProtoType) if err != nil { return nil, errors.Wrapf(err, "failed to get output type resolver for method: %s", method.Name) } + + if outProtoTypeRepeated { + outType = graphql.GqlListTypeResolver(graphql.GqlNonNullTypeResolver(outType)) + } + requestType, err := g.goTypeByParserType(method.InputMessage) if err != nil { return nil, errors.Wrapf(err, "failed to get request go type for method: %s", method.Name) @@ -177,14 +197,41 @@ func (g Proto2GraphQL) serviceMethod(sc ServiceConfig, cfg MethodConfig, file *p return nil, errors.Wrap(err, "failed add data loader provider") } + clientMethodCaller := func(client, arg string, ctx graphql.BodyContext) string { + return client + "." + camelCase(method.Name) + "(ctx," + arg + ")" + } + + if len(method.OutputMessage.Fields) == 1 && !cfg.UnwrapResponseField { + fmt.Printf( + "Suggestion: service `%s` method `%s` in file `%s` has 1 output field. Can be unwrapped.\n", + method.Service.Name, + method.Name, + file.File.FilePath, + ) + } + + if cfg.UnwrapResponseField { + unwrapFieldName := camelCase(method.OutputMessage.Fields[0].Name) + + clientMethodCaller = func(client, arg string, ctx graphql.BodyContext) string { + return `func() (interface{}, error) { + res, err := ` + client + "." + camelCase(method.Name) + `(ctx,` + arg + `) + + if err != nil { + return nil, err + } + + return res.` + unwrapFieldName + `, nil + }()` + } + } + return &graphql.Method{ - Name: g.methodName(cfg, method), - QuotedComment: method.QuotedComment, - GraphQLOutputType: outType, - RequestType: requestType, - ClientMethodCaller: func(client, arg string, ctx graphql.BodyContext) string { - return client + "." + camelCase(method.Name) + "(ctx," + arg + ")" - }, + Name: g.methodName(cfg, method), + QuotedComment: method.QuotedComment, + GraphQLOutputType: outType, + RequestType: requestType, + ClientMethodCaller: clientMethodCaller, RequestResolver: valueResolver, RequestResolverWithErr: valueResolverWithErr, Arguments: args, @@ -204,7 +251,34 @@ func (g Proto2GraphQL) addDataLoaderProvider(sc ServiceConfig, cfg MethodConfig, return errors.New("Method " + method.Name + " must have 1 response argument") } - responseGoType, err := g.goTypeByParserType(method.OutputMessage.Fields[0].Type) + var outProtoType *parser.Field + + if cfg.UnwrapResponseField { + if len(method.OutputMessage.Fields) != 1 { + return errors.Errorf("response field unwrapping failed for method: %s. Output message must have 1 field", method.Name) + } + + _, ok := method.OutputMessage.Fields[0].Type.(*parser.Message) + + if !ok { + return errors.Errorf("can't unwrap %s method. Response must be message", method.Name) + } + + outProtoType = method.OutputMessage.Fields[0].Type.(*parser.Message).Fields[0] + } else { + outProtoType = method.OutputMessage.Fields[0] + } + + responseGoType, err := g.goTypeByParserType(outProtoType.Type) + + if cfg.UnwrapResponseField && outProtoType.Repeated { + elementGoType := responseGoType + + responseGoType = graphql.GoType{ + Kind: reflect.Slice, + ElemType: &elementGoType, + } + } if err != nil { return err @@ -234,31 +308,24 @@ func (g Proto2GraphQL) addDataLoaderProvider(sc ServiceConfig, cfg MethodConfig, return errors.Wrap(err, "failed to resolve file type file") } - dataLoaderOutType, err := g.TypeOutputTypeResolver(outputMsgTypeFile, method.OutputMessage.Fields[0].Type) + dataLoaderOutType, err := g.TypeOutputTypeResolver(outputMsgTypeFile, outProtoType.Type) if err != nil { return errors.Wrap(err, "failed to resolve output type") } + fetchCode := g.dataLoaderFetchCode(file, method) + + if cfg.UnwrapResponseField && outProtoType.Repeated { + dataLoaderOutType = graphql.GqlListTypeResolver(graphql.GqlNonNullTypeResolver(dataLoaderOutType)) + fetchCode = g.dataLoaderFetchCodeUnwrappedSlice(file, method, responseGoType, outProtoType) + } + dataLoaderProvider := dataloader.LoaderModel{ Service: &dataloader.Service{ Name: g.serviceName(sc, svc), CallInterface: g.serviceCallInterface(file, svc.Name), }, - FetchCode: func(importer *importer.Importer) string { - requestTypeName := method.InputMessage.Name - requestFieldName := camelCase(method.InputMessage.Fields[0].Name) - responseFieldName := camelCase(method.OutputMessage.Fields[0].Name) - - return ` - request := &` + importer.Prefix(file.GRPCSourcesPkg) + requestTypeName + `{ - ` + requestFieldName + `: keys, - } - - response, err := client.` + method.Name + `(ctx, request) - - return response.` + responseFieldName + `, []error{err} - ` - }, + FetchCode: fetchCode, InputGoType: graphql.GoType{ Kind: reflect.Slice, ElemType: &inputArgumentGoType, @@ -272,6 +339,51 @@ func (g Proto2GraphQL) addDataLoaderProvider(sc ServiceConfig, cfg MethodConfig, return nil } +func (g Proto2GraphQL) dataLoaderFetchCode(file *parsedFile, method *parser.Method) func(importer *importer.Importer) string { + return func(importer *importer.Importer) string { + requestTypeName := method.InputMessage.Name + requestFieldName := camelCase(method.InputMessage.Fields[0].Name) + responseFieldName := camelCase(method.OutputMessage.Fields[0].Name) + + return ` + request := &` + importer.Prefix(file.GRPCSourcesPkg) + requestTypeName + `{ + ` + requestFieldName + `: keys, + } + + response, err := client.` + method.Name + `(ctx, request) + + return response.` + responseFieldName + `, []error{err} + ` + } +} + +func (g Proto2GraphQL) dataLoaderFetchCodeUnwrappedSlice(file *parsedFile, method *parser.Method, responseGoType graphql.GoType, outProtoType *parser.Field) func(importer *importer.Importer) string { + return func(importer *importer.Importer) string { + requestTypeName := method.InputMessage.Name + requestFieldName := camelCase(method.InputMessage.Fields[0].Name) + responseFieldName := camelCase(method.OutputMessage.Fields[0].Name) + + responseGoTypeString := responseGoType.String(importer) + + outProtoTypeName := camelCase(outProtoType.Name) + + return ` + request := &` + importer.Prefix(file.GRPCSourcesPkg) + requestTypeName + `{ + ` + requestFieldName + `: keys, + } + + response, err := client.` + method.Name + `(ctx, request) + + var normalized []` + responseGoTypeString + ` + + for _, item := range response.` + responseFieldName + ` { + normalized = append(normalized, item.` + outProtoTypeName + `) + } + + return normalized, []error{err} + ` + } +} func (g Proto2GraphQL) serviceQueryMethods(sc ServiceConfig, file *parsedFile, service *parser.Service) ([]graphql.Method, error) { var res []graphql.Method diff --git a/tests/Makefile b/tests/Makefile index 24aee4f..e4535c3 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -1,2 +1,3 @@ test: $(MAKE) -C dataloader/ + $(MAKE) -C proto_unwrap/ diff --git a/tests/dataloader/dataloader_test.go b/tests/dataloader/dataloader_test.go index 0dd5550..2e56016 100644 --- a/tests/dataloader/dataloader_test.go +++ b/tests/dataloader/dataloader_test.go @@ -64,11 +64,9 @@ func TestDataLoader(t *testing.T) { Query: `{ items { list { - items { + name + category { name - category { - name - } } } } @@ -78,8 +76,7 @@ func TestDataLoader(t *testing.T) { tests.AssertJSON(t, `{ "data": { "items": { - "list": { - "items": [ + "list": [ { "name": "item 1", "category": { @@ -92,8 +89,7 @@ func TestDataLoader(t *testing.T) { "name": "category 11" } } - ] - } + ] } } }`, response) @@ -139,11 +135,9 @@ func TestDataLoaderServiceMakeOnlyOneRequest(t *testing.T) { Query: `{ items { list { - items { + name + category { name - category { - name - } } } } @@ -249,13 +243,11 @@ func TestDataLoaderWithSwagger(t *testing.T) { Query: `{ items { list { - items { - comments { - id - text - user { - name - } + comments { + id + text + user { + name } } } @@ -266,27 +258,25 @@ func TestDataLoaderWithSwagger(t *testing.T) { tests.AssertJSON(t, `{ "data": { "items": { - "list": { - "items": [ - { - "comments": [ - { - "id": 111, - "text": "test comment", - "user": { - "name": "Test User" - } - } - ] - } - ] - } + "list": [ + { + "comments": [ + { + "id": 111, + "text": "test comment", + "user": { + "name": "Test User" + } + } + ] + } + ] } } }`, response) } -func TestDataLoaderGetOneWithGRPCArrayLoader(t *testing.T) { +func TestDataLoaderWithProtoFieldUnwrapping(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() @@ -320,9 +310,7 @@ func TestDataLoaderGetOneWithGRPCArrayLoader(t *testing.T) { items { GetOne { reviews { - item_review { - text - } + text } } } @@ -333,32 +321,15 @@ func TestDataLoaderGetOneWithGRPCArrayLoader(t *testing.T) { "data": { "items": { "GetOne": { - "reviews": { - "item_review": [ - { - "text": "excellent item" - } - ] - } + "reviews": [ + { + "text": "excellent item" + } + ] } } } }`, response) - - // TODO: after proto field unwrapping implementation should be: - //tests.AssertJSON(t, `{ - // "data": { - // "items": { - // "GetOne": { - // "reviews": [ - // { - // "text": "excellent item" - // } - // ] - // } - // } - // } - //}`, response) } func makeRequest(t *testing.T, clients *mock.Clients, opts *handler.RequestOptions) *graphql.Result { diff --git a/tests/dataloader/generate.yml b/tests/dataloader/generate.yml index de3d8de..9d94b5f 100644 --- a/tests/dataloader/generate.yml +++ b/tests/dataloader/generate.yml @@ -14,6 +14,7 @@ proto2gql: ItemsReviewService: methods: List: + unwrap_response_field: true data_loader_provider: name: "ItemReviewsLoader" - proto_path: "./apis/category.proto" @@ -38,6 +39,7 @@ proto2gql: ItemsService: methods: List: + unwrap_response_field: true alias: "list" request_type: "QUERY" messages: diff --git a/tests/proto_unwrap/Makefile b/tests/proto_unwrap/Makefile new file mode 100644 index 0000000..4aae572 --- /dev/null +++ b/tests/proto_unwrap/Makefile @@ -0,0 +1,5 @@ +dataloader_tests: + mkdir -p generated/clients + protoc --go_out=paths=source_relative,plugins=grpc:generated/clients apis/items.proto + + go run ../../cmd/go2gql/main.go ../../cmd/go2gql/basic_plugins.go diff --git a/tests/proto_unwrap/apis/items.proto b/tests/proto_unwrap/apis/items.proto new file mode 100644 index 0000000..6e2ede4 --- /dev/null +++ b/tests/proto_unwrap/apis/items.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package apis; + +option go_package = "github.com/EGT-Ukraine/go2gql/tests/proto_unwrap/generated/clients/apis;apis"; + +import "google/protobuf/empty.proto"; + +service ItemsService { + rpc List (google.protobuf.Empty) returns (ItemListResponse) {} + rpc Activated (google.protobuf.Empty) returns (ActivatedResponse) {} +} + +message ActivatedResponse { + bool activated = 1; +} + +message ItemListResponse { + repeated Item items = 1; +} + +message Item { + int64 id = 1; + string name = 2; +} diff --git a/tests/proto_unwrap/generate.yml b/tests/proto_unwrap/generate.yml new file mode 100644 index 0000000..b7abb1b --- /dev/null +++ b/tests/proto_unwrap/generate.yml @@ -0,0 +1,35 @@ +data_loaders: + output_path: "./generated/schema/loaders/" + +proto2gql: + output_path: "./generated/schema" + paths: + - "vendor" + - "$GOPATH/src" + imports_aliases: + - google/protobuf/empty.proto: "github.com/golang/protobuf/ptypes/empty/empty.proto" + files: + - proto_path: "./apis/items.proto" + services: + ItemsService: + methods: + List: + unwrap_response_field: true + alias: "list" + request_type: "QUERY" + Activated: + unwrap_response_field: true + alias: "activated" + request_type: "QUERY" + +graphql_schemas: + - name: "API" + output_path: "./generated/schema/api.go" + output_package: "schema" + queries: + type: "OBJECT" + fields: + - field: "items" + object_name: "Items" + service: "ItemsService" + type: "SERVICE" diff --git a/tests/proto_unwrap/mock/item.go b/tests/proto_unwrap/mock/item.go new file mode 100644 index 0000000..91d9524 --- /dev/null +++ b/tests/proto_unwrap/mock/item.go @@ -0,0 +1,77 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/EGT-Ukraine/go2gql/tests/proto_unwrap/generated/clients/apis (interfaces: ItemsServiceClient) + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + apis "github.com/EGT-Ukraine/go2gql/tests/proto_unwrap/generated/clients/apis" + gomock "github.com/golang/mock/gomock" + empty "github.com/golang/protobuf/ptypes/empty" + grpc "google.golang.org/grpc" + reflect "reflect" +) + +// MockItemsServiceClient is a mock of ItemsServiceClient interface +type MockItemsServiceClient struct { + ctrl *gomock.Controller + recorder *MockItemsServiceClientMockRecorder +} + +// MockItemsServiceClientMockRecorder is the mock recorder for MockItemsServiceClient +type MockItemsServiceClientMockRecorder struct { + mock *MockItemsServiceClient +} + +// NewMockItemsServiceClient creates a new mock instance +func NewMockItemsServiceClient(ctrl *gomock.Controller) *MockItemsServiceClient { + mock := &MockItemsServiceClient{ctrl: ctrl} + mock.recorder = &MockItemsServiceClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockItemsServiceClient) EXPECT() *MockItemsServiceClientMockRecorder { + return m.recorder +} + +// Activated mocks base method +func (m *MockItemsServiceClient) Activated(arg0 context.Context, arg1 *empty.Empty, arg2 ...grpc.CallOption) (*apis.ActivatedResponse, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Activated", varargs...) + ret0, _ := ret[0].(*apis.ActivatedResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Activated indicates an expected call of Activated +func (mr *MockItemsServiceClientMockRecorder) Activated(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Activated", reflect.TypeOf((*MockItemsServiceClient)(nil).Activated), varargs...) +} + +// List mocks base method +func (m *MockItemsServiceClient) List(arg0 context.Context, arg1 *empty.Empty, arg2 ...grpc.CallOption) (*apis.ItemListResponse, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(*apis.ItemListResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List +func (mr *MockItemsServiceClientMockRecorder) List(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockItemsServiceClient)(nil).List), varargs...) +} diff --git a/tests/proto_unwrap/mock/loader.go b/tests/proto_unwrap/mock/loader.go new file mode 100644 index 0000000..4f13e8a --- /dev/null +++ b/tests/proto_unwrap/mock/loader.go @@ -0,0 +1,9 @@ +package mock + +import ( + "github.com/EGT-Ukraine/go2gql/tests/proto_unwrap/generated/clients/apis" +) + +type Clients struct { + ItemsClient apis.ItemsServiceClient +} diff --git a/tests/proto_unwrap/proto_unwrap_test.go b/tests/proto_unwrap/proto_unwrap_test.go new file mode 100644 index 0000000..bde6089 --- /dev/null +++ b/tests/proto_unwrap/proto_unwrap_test.go @@ -0,0 +1,115 @@ +package proto_unwrap + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/graphql-go/graphql" + "github.com/graphql-go/handler" + + "github.com/EGT-Ukraine/go2gql/api/interceptors" + "github.com/EGT-Ukraine/go2gql/tests" + "github.com/EGT-Ukraine/go2gql/tests/proto_unwrap/generated/clients/apis" + "github.com/EGT-Ukraine/go2gql/tests/proto_unwrap/generated/schema" + "github.com/EGT-Ukraine/go2gql/tests/proto_unwrap/mock" +) + +//go:generate mockgen -destination=mock/item.go -package=mock github.com/EGT-Ukraine/go2gql/tests/proto_unwrap/generated/clients/apis ItemsServiceClient + +func TestProtoResponseFieldUnwrapping(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + itemsClient := mock.NewMockItemsServiceClient(mockCtrl) + itemsClient.EXPECT().List(gomock.Any(), gomock.Any()).Return(&apis.ItemListResponse{ + Items: []*apis.Item{ + { + Id: 11, + Name: "item 1", + }, + { + Id: 12, + Name: "item 2", + }, + }, + }, nil).AnyTimes() + + clients := &mock.Clients{ + ItemsClient: itemsClient, + } + + response := makeRequest(t, clients, &handler.RequestOptions{ + Query: `{ + items { + list { + id + name + } + } + }`, + }) + + tests.AssertJSON(t, `{ + "data": { + "items": { + "list": [ + { + "id": 11, + "name": "item 1" + }, + { + "id": 12, + "name": "item 2" + } + ] + } + } + }`, response) +} + +func TestProtoResponseScalarFieldUnwrapping(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + itemsClient := mock.NewMockItemsServiceClient(mockCtrl) + itemsClient.EXPECT().Activated(gomock.Any(), gomock.Any()).Return(&apis.ActivatedResponse{ + Activated: true, + }, nil).AnyTimes() + + clients := &mock.Clients{ + ItemsClient: itemsClient, + } + + response := makeRequest(t, clients, &handler.RequestOptions{ + Query: `{ + items { + activated + } + }`, + }) + + tests.AssertJSON(t, `{ + "data": { + "items": { + "activated": true + } + } + }`, response) +} + +func makeRequest(t *testing.T, clients *mock.Clients, opts *handler.RequestOptions) *graphql.Result { + schemaClients := schema.APISchemaClients{ + ItemsServiceClient: clients.ItemsClient, + } + + apiSchema, err := schema.GetAPISchema(schemaClients, &interceptors.InterceptorHandler{}) + + if err != nil { + t.Fatalf(err.Error()) + } + + ctx := context.Background() + + return tests.MakeRequest(apiSchema, opts, ctx) +}