From 0413eece35ad7393c43a6ad40420b2720740aeed Mon Sep 17 00:00:00 2001 From: Yasuyuki Takeo Date: Sat, 27 Jul 2024 05:29:04 +0900 Subject: [PATCH] Add gqlgen custom code generator --- backend/Makefile | 2 +- backend/README.md | 5 ++ backend/cmd/gqlgenerate/main.go | 76 +++++++++++++++++++++++++++- backend/cmd/gqlgenerate/main_test.go | 61 ++++++++++++++++++++++ backend/gqlgen.yml | 3 ++ backend/graph/db/model.go | 30 +++++------ backend/graph/directive.go | 50 ++++++++++++++++++ backend/graph/generated.go | 40 ++++++++++++++- backend/graph/model/models_gen.go | 26 +++++----- backend/graph/schema.graphqls | 28 +++++----- 10 files changed, 278 insertions(+), 43 deletions(-) create mode 100644 backend/cmd/gqlgenerate/main_test.go diff --git a/backend/Makefile b/backend/Makefile index 0734dd1..e01ceb5 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -27,7 +27,7 @@ gen: ## Generate GraphQL implementations printf "${GREEN} Update gqlgen\n\n"; \ go get github.com/99designs/gqlgen@latest; cd ${ORG_PATH} ; \ printf "${GREEN} Generating GraphQL related sources\n\n"; \ - go run github.com/99designs/gqlgen generate; cd ${ORG_PATH}; \ + go run ./cmd/gqlgenerate; cd ${ORG_PATH}; \ printf "${GREEN}Done\n"; \ .PHONY: fmt diff --git a/backend/README.md b/backend/README.md index 0bf3349..6da80b7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -45,6 +45,11 @@ This backend uses some generated code. Here are the steps to generate code. 2. At `/backend`, run `make parsergen` # Tips +## How to generate GraphQL Model under /graph/model +Right under the +``` + go run ./cmd/gqlgenerate +``` ## How to see the Make Commands ``` make help diff --git a/backend/cmd/gqlgenerate/main.go b/backend/cmd/gqlgenerate/main.go index 3616f2d..2703b9a 100644 --- a/backend/cmd/gqlgenerate/main.go +++ b/backend/cmd/gqlgenerate/main.go @@ -1 +1,75 @@ -package gqlgenerate +package main + +import ( + "fmt" + "log" + "os" + + "github.com/99designs/gqlgen/api" + "github.com/99designs/gqlgen/codegen/config" + "github.com/99designs/gqlgen/plugin/modelgen" + "github.com/vektah/gqlparser/v2/ast" +) + +// Exit codes +const ( + exitCodeConfigLoadError = 2 + exitCodeGenerateError = 3 +) + +func main() { + // Initialize logger + logger := log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile) + + // Load the GraphQL configuration + cfg, err := loadGraphQLConfig() + if err != nil { + logger.Fatalf("Error loading GraphQL config: %v", err) + } + + // Generate the GraphQL server code + err = generateGraphQLCode(cfg, logger) + if err != nil { + logger.Fatalf("Error generating GraphQL code: %v", err) + } +} + +// loadGraphQLConfig loads the GraphQL configuration from default locations +func loadGraphQLConfig() (*config.Config, error) { + cfg, err := config.LoadConfigFromDefaultLocations() + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + return cfg, nil +} + +// generateGraphQLCode generates the GraphQL server code using the provided config +func generateGraphQLCode(cfg *config.Config, logger *log.Logger) error { + // Attaching the mutation function onto modelgen plugin + p := modelgen.Plugin{ + FieldHook: ValidationFieldHook, + } + + // Generate the code using the API + err := api.Generate(cfg, api.ReplacePlugin(&p)) + if err != nil { + return fmt.Errorf("code generation failed: %w", err) + } + logger.Println("GraphQL code generation successful") + return nil +} + +// ValidationFieldHook is a custom hook for adding validation tags to fields based on directives +func ValidationFieldHook(td *ast.Definition, fd *ast.FieldDefinition, f *modelgen.Field) (*modelgen.Field, error) { + // Look for the "validation" directive on the field + c := fd.Directives.ForName("validation") + if c != nil { + // Add validation tag based on the "format" argument in the directive + formatConstraint := c.Arguments.ForName("format") + if formatConstraint != nil { + // Use a format that avoids double quoting + f.Tag += fmt.Sprintf(` validate:"%s"`, formatConstraint.Value.Raw) + } + } + return f, nil +} diff --git a/backend/cmd/gqlgenerate/main_test.go b/backend/cmd/gqlgenerate/main_test.go new file mode 100644 index 0000000..986a22c --- /dev/null +++ b/backend/cmd/gqlgenerate/main_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "log" + "os" + "testing" + + "github.com/99designs/gqlgen/plugin/modelgen" + "github.com/vektah/gqlparser/v2/ast" +) + +func TestLoadGraphQLConfig(t *testing.T) { + _, err := loadGraphQLConfig() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} + +func TestGenerateGraphQLCode(t *testing.T) { + logger := log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile) + cfg, err := loadGraphQLConfig() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + err = generateGraphQLCode(cfg, logger) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} + +func TestValidationFieldHook(t *testing.T) { + // Mock field definitions and directive + fd := &ast.FieldDefinition{ + Directives: ast.DirectiveList{ + { + Name: "validation", + Arguments: ast.ArgumentList{ + { + Name: "format", + Value: &ast.Value{ + Raw: "email", + Kind: ast.StringValue, + }, + }, + }, + }, + }, + } + f := &modelgen.Field{} + + _, err := ValidationFieldHook(nil, fd, f) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + expectedTag := ` validate:"email"` + if f.Tag != expectedTag { + t.Fatalf("Expected tag %v, got %v", expectedTag, f.Tag) + } +} diff --git a/backend/gqlgen.yml b/backend/gqlgen.yml index 849d5b2..5a2480f 100644 --- a/backend/gqlgen.yml +++ b/backend/gqlgen.yml @@ -85,3 +85,6 @@ models: Time: model: - github.com/99designs/gqlgen/graphql.Time +directives: + validation: + skip_runtime: true \ No newline at end of file diff --git a/backend/graph/db/model.go b/backend/graph/db/model.go index e8720b1..a26a96b 100644 --- a/backend/graph/db/model.go +++ b/backend/graph/db/model.go @@ -7,36 +7,36 @@ import ( type User struct { ID int64 `gorm:"column:id;primaryKey"` Name string `gorm:"column:name;not null" validate:"required,fl_name"` - Created time.Time `gorm:"column:created;autoCreateTime" validate:"required"` - Updated time.Time `gorm:"column:updated;autoCreateTime" validate:"required"` + Created time.Time `gorm:"column:created;autoCreateTime"` + Updated time.Time `gorm:"column:updated;autoCreateTime"` CardGroups []Cardgroup `gorm:"many2many:cardgroup_users"` Roles []Role `gorm:"many2many:user_roles"` } type Card struct { ID int64 `gorm:"column:id;primaryKey"` - Front string `gorm:"column:front;not null" validate:"required"` - Back string `gorm:"column:back;not null" validate:"required"` - ReviewDate time.Time `gorm:"column:review_date;not null" validate:"required"` - IntervalDays int `gorm:"column:interval_days;default:1;not null" validate:"required"` - Created time.Time `gorm:"column:created;autoCreateTime" validate:"required"` - Updated time.Time `gorm:"column:updated;autoCreateTime" validate:"required"` - CardGroupID int64 `gorm:"column:cardgroup_id" validate:"required"` + Front string `gorm:"column:front;not null" validate:"required,min=1"` + Back string `gorm:"column:back;not null" validate:"required,min=1"` + ReviewDate time.Time `gorm:"column:review_date;not null"` + IntervalDays int `gorm:"column:interval_days;default:1;not null" validate:"gee=1"` + Created time.Time `gorm:"column:created;autoCreateTime"` + Updated time.Time `gorm:"column:updated;autoCreateTime"` + CardGroupID int64 `gorm:"column:cardgroup_id"` } type Cardgroup struct { ID int64 `gorm:"column:id;primaryKey"` - Name string `gorm:"column:name;not null" validate:"required,fl_name"` - Created time.Time `gorm:"column:created;autoCreateTime" validate:"required"` - Updated time.Time `gorm:"column:updated;autoCreateTime" validate:"required"` + Name string `gorm:"column:name;not null" validate:"required,fl_name,min=1"` + Created time.Time `gorm:"column:created;autoCreateTime"` + Updated time.Time `gorm:"column:updated;autoCreateTime"` Cards []Card `gorm:"foreignKey:CardGroupID"` Users []User `gorm:"many2many:cardgroup_users"` } type Role struct { ID int64 `gorm:"column:id;primaryKey"` - Name string `gorm:"column:name;not null" validate:"required,fl_name"` + Name string `gorm:"column:name;not null" validate:"required,fl_name,min=1"` Users []User `gorm:"many2many:user_roles"` - Created time.Time `gorm:"column:created;autoCreateTime" validate:"required"` - Updated time.Time `gorm:"column:updated;autoCreateTime" validate:"required"` + Created time.Time `gorm:"column:created;autoCreateTime"` + Updated time.Time `gorm:"column:updated;autoCreateTime"` } diff --git a/backend/graph/directive.go b/backend/graph/directive.go index e103ddd..19c6f8f 100644 --- a/backend/graph/directive.go +++ b/backend/graph/directive.go @@ -1 +1,51 @@ package graph + +import ( + "context" + "errors" + "regexp" + + "github.com/99designs/gqlgen/graphql" +) + +//gqlgen + +// +// #directive @constraint( +//# minLength: Int, +//# maxLength: Int, +//# min: Int, +//# max: Int, +//# pattern: String) on INPUT_FIELD_DEFINITION + +func Constraint(ctx context.Context, obj interface{}, next graphql.Resolver, minLength *int, maxLength *int, min *int, max *int, pattern *string) (interface{}, error) { + val, err := next(ctx) + if err != nil { + return nil, err + } + + switch v := val.(type) { + case string: + if minLength != nil && len(v) < *minLength { + return nil, errors.New("value is too short") + } + if maxLength != nil && len(v) > *maxLength { + return nil, errors.New("value is too long") + } + if pattern != nil { + matched, _ := regexp.MatchString(*pattern, v) + if !matched { + return nil, errors.New("value does not match pattern") + } + } + case int: + if min != nil && v < *min { + return nil, errors.New("value is too small") + } + if max != nil && v > *max { + return nil, errors.New("value is too large") + } + } + + return val, nil +} diff --git a/backend/graph/generated.go b/backend/graph/generated.go index 2b150c6..554e2b4 100644 --- a/backend/graph/generated.go +++ b/backend/graph/generated.go @@ -6287,7 +6287,7 @@ func (ec *executionContext) unmarshalInputNewCardGroup(ctx context.Context, obj it.Name = data case "card_ids": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("card_ids")) - data, err := ec.unmarshalNID2ᚕint64ᚄ(ctx, v) + data, err := ec.unmarshalOID2ᚕint64ᚄ(ctx, v) if err != nil { return it, err } @@ -8108,6 +8108,44 @@ func (ec *executionContext) marshalOCardGroup2ᚖbackendᚋgraphᚋmodelᚐCardG return ec._CardGroup(ctx, sel, v) } +func (ec *executionContext) unmarshalOID2ᚕint64ᚄ(ctx context.Context, v interface{}) ([]int64, error) { + if v == nil { + return nil, nil + } + var vSlice []interface{} + if v != nil { + vSlice = graphql.CoerceList(v) + } + var err error + res := make([]int64, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNID2int64(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalOID2ᚕint64ᚄ(ctx context.Context, sel ast.SelectionSet, v []int64) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + for i := range v { + ret[i] = ec.marshalNID2int64(ctx, sel, v[i]) + } + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) unmarshalOInt2ᚖint(ctx context.Context, v interface{}) (*int, error) { if v == nil { return nil, nil diff --git a/backend/graph/model/models_gen.go b/backend/graph/model/models_gen.go index 11a66c3..111a6ac 100644 --- a/backend/graph/model/models_gen.go +++ b/backend/graph/model/models_gen.go @@ -8,10 +8,10 @@ import ( type Card struct { ID int64 `json:"id"` - Front string `json:"front"` - Back string `json:"back"` + Front string `json:"front" validate:"required,min=1"` + Back string `json:"back" validate:"required,min=1"` ReviewDate time.Time `json:"review_date"` - IntervalDays int `json:"interval_days"` + IntervalDays int `json:"interval_days" validate:"gte=1"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` CardGroup *CardGroup `json:"cardGroup"` @@ -19,7 +19,7 @@ type Card struct { type CardGroup struct { ID int64 `json:"id"` - Name string `json:"name"` + Name string `json:"name" validate:"required,fl_name,min=1"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` Cards []*Card `json:"cards"` @@ -30,31 +30,31 @@ type Mutation struct { } type NewCard struct { - Front string `json:"front"` - Back string `json:"back"` + Front string `json:"front" validate:"required,min=1"` + Back string `json:"back" validate:"required,min=1"` ReviewDate time.Time `json:"review_date"` - IntervalDays *int `json:"interval_days,omitempty"` + IntervalDays *int `json:"interval_days,omitempty" validate:"gte=1"` CardgroupID int64 `json:"cardgroup_id"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } type NewCardGroup struct { - Name string `json:"name"` - CardIds []int64 `json:"card_ids"` + Name string `json:"name" validate:"required,min=1"` + CardIds []int64 `json:"card_ids,omitempty"` UserIds []int64 `json:"user_ids"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } type NewRole struct { - Name string `json:"name"` + Name string `json:"name" validate:"required,fl_name,min=1"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } type NewUser struct { - Name string `json:"name"` + Name string `json:"name" validate:"required,fl_name,min=1"` RoleIds []int64 `json:"role_ids"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` @@ -65,7 +65,7 @@ type Query struct { type Role struct { ID int64 `json:"id"` - Name string `json:"name"` + Name string `json:"name" validate:"required,fl_name,min=1"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` Users []*User `json:"users"` @@ -73,7 +73,7 @@ type Role struct { type User struct { ID int64 `json:"id"` - Name string `json:"name"` + Name string `json:"name" validate:"required,fl_name,min=1"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` CardGroups []*CardGroup `json:"cardGroups"` diff --git a/backend/graph/schema.graphqls b/backend/graph/schema.graphqls index c2b0a5e..81b4e02 100644 --- a/backend/graph/schema.graphqls +++ b/backend/graph/schema.graphqls @@ -1,11 +1,15 @@ scalar Time +directive @validation( + format: String +) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION | FIELD_DEFINITION + type Card { id: ID! - front: String! - back: String! + front: String! @validation(format: "required,min=1") + back: String! @validation(format: "required,min=1") review_date: Time! - interval_days: Int! + interval_days: Int! @validation(format: "gte=1") created: Time! updated: Time! cardGroup: CardGroup! @@ -13,7 +17,7 @@ type Card { type CardGroup { id: ID! - name: String! + name: String! @validation(format: "required,fl_name,min=1") created: Time! updated: Time! cards: [Card!]! @@ -22,7 +26,7 @@ type CardGroup { type Role { id: ID! - name: String! + name: String! @validation(format: "required,fl_name,min=1") created: Time! updated: Time! users: [User!]! @@ -30,7 +34,7 @@ type Role { type User { id: ID! - name: String! + name: String! @validation(format: "required,fl_name,min=1") created: Time! updated: Time! cardGroups: [CardGroup!]! @@ -38,17 +42,17 @@ type User { } input NewCard { - front: String! - back: String! + front: String! @validation(format: "required,min=1") + back: String! @validation(format: "required,min=1") review_date: Time! - interval_days: Int = 1 + interval_days: Int = 1 @validation(format: "gte=1") cardgroup_id: ID!, created: Time!, updated: Time!, } input NewCardGroup { - name: String!, + name: String!, @validation(format: "required,min=1") card_ids: [ID!] user_ids: [ID!]! created: Time!, @@ -56,14 +60,14 @@ input NewCardGroup { } input NewUser { - name: String!, + name: String!, @validation(format: "required,fl_name,min=1") role_ids: [ID!]! created: Time!, updated: Time!, } input NewRole { - name: String! + name: String! @validation(format: "required,fl_name,min=1") created: Time!, updated: Time!, }