diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 23310c0..6c7f7a4 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -13,6 +13,7 @@ func main() { webappCmd(), tailwindCmd(), setupCmd(), + vueGenCmd(), } app := &cli.App{ diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 796057f..50c5b36 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -8,6 +8,7 @@ import ( "github.com/urfave/cli/v2" "golang.org/x/text/cases" "golang.org/x/text/language" + vue_gen "masonry/vue-gen" "os" "os/exec" "runtime" @@ -16,10 +17,13 @@ import ( ) //go:embed templates/proto/application.proto.tmpl -var protoTemplate string +var protoTemplateSrc string //go:embed templates/backend/main.go.tmpl -var mainGoTemplate string +var mainGoTemplateSrc string + +//go:embed templates/backend/gitignore.tmpl +var gitignoreTemplateSrc string //go:embed proto_include/* var protoInclude embed.FS @@ -98,12 +102,26 @@ func createCmd() *cli.Command { titleMaker := cases.Title(language.English) // render the main.go file from the template - goTemplate := template.Must(template.New("main").Parse(mainGoTemplate)) + goTemplate := template.Must(template.New("main").Parse(mainGoTemplateSrc)) err = goTemplate.Execute(mainFile, map[string]string{"AppName": strings.ToLower(applicationName), "AppNameCaps": titleMaker.String(applicationName)}) if err != nil { return fmt.Errorf("error rendering main.go file | %w", err) } + // create a gitignore file + gitignoreFile, err := os.Create(".gitignore") + if err != nil { + return fmt.Errorf("error creating gitignore file | %w", err) + } + defer gitignoreFile.Close() + + // render the gitignore file from the template + gitignoreTemplate := template.Must(template.New("gitignore").Parse(gitignoreTemplateSrc)) + err = gitignoreTemplate.Execute(gitignoreFile, nil) + if err != nil { + return fmt.Errorf("error rendering gitignore file | %w", err) + } + // create a proto file protoFile, err := os.Create("proto/service.proto") if err != nil { @@ -112,7 +130,7 @@ func createCmd() *cli.Command { defer protoFile.Close() // render the proto file from the template - t := template.Must(template.New("proto").Parse(protoTemplate)) + t := template.Must(template.New("proto").Parse(protoTemplateSrc)) err = t.Execute(protoFile, map[string]string{"AppName": strings.ToLower(applicationName), "AppNameCaps": titleMaker.String(applicationName), "ObjName": "Product"}) if err != nil { return fmt.Errorf("error rendering proto file | %w", err) @@ -207,24 +225,24 @@ func generateCmd() *cli.Command { return fmt.Errorf("error generating typescript code | %w", err) } - // if on windows - if runtime.GOOS == "windows" { - //cmd = exec.Command("npx", "protoc", "--plugin=protoc-gen-ts_proto=.\\node_modules\\.bin\\protoc-gen-ts_proto.cmd", "--ts_proto_out=./src/generated", "--ts_proto_opt=outputServices=generic,esModuleInterop=true", "--proto_path=../proto", "--proto_path=../include", "../proto/service.proto") - //cmd.Stdout = os.Stdout - //cmd.Stderr = os.Stderr - //err = cmd.Run() - //if err != nil { - // return fmt.Errorf("error generating typescript code | %w", err) - //} - } else { - //cmd = exec.Command("npx", "protoc", "--plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto", "--ts_proto_out=./src/generated", "--ts_proto_opt=outputServices=generic,esModuleInterop=true", "--proto_path=../proto", "--proto_path=../include", "../proto/service.proto") - //cmd.Stdout = os.Stdout - //cmd.Stderr = os.Stderr - //err = cmd.Run() - //if err != nil { - // return fmt.Errorf("error generating typescript code | %w", err) - //} + // make sure src/generated-components exists + err = os.Mkdir("src/generated-components", 0755) + if err != nil { + return fmt.Errorf("error creating src/generated-components directory | %w", err) } + + // generate vue components + err = vue_gen.GenVueFromSwagger("../gen/openapi/proto/service.swagger.json", "src/generated-components") + if err != nil { + return fmt.Errorf("error generating vue components | %w", err) + } + + cmd := exec.Command("npm", "install", "@masonitestudios/dynamic-vue") + err = cmd.Run() + if err != nil { + return fmt.Errorf("error installing @masonitestudios/dynamic-vue | %w", err) + } + err = os.Chdir("..") if err != nil { return fmt.Errorf("error changing directory back to root | %w", err) @@ -299,3 +317,37 @@ func setupCmd() *cli.Command { }, } } + +func vueGenCmd() *cli.Command { + return &cli.Command{ + Name: "vuegen", + Aliases: []string{"vg"}, + Usage: "Generate vue components based on a swagger file", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "input", + Aliases: []string{"i"}, + Usage: "The input swagger file", + Required: true, + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "The output directory", + Required: true, + }, + }, + Action: func(c *cli.Context) error { + fmt.Println("Generating vue components") + input := c.String("input") + output := c.String("output") + + err := vue_gen.GenVueFromSwagger(input, output) + if err != nil { + return fmt.Errorf("error generating vue components | %w", err) + } + + return nil + }, + } +} diff --git a/cmd/cli/templates/backend/gitignore.tmpl b/cmd/cli/templates/backend/gitignore.tmpl new file mode 100644 index 0000000..b11c694 --- /dev/null +++ b/cmd/cli/templates/backend/gitignore.tmpl @@ -0,0 +1,3 @@ +local.db +gen/ +webapp/src/generated/ \ No newline at end of file diff --git a/cmd/cli/templates/backend/main.go.tmpl b/cmd/cli/templates/backend/main.go.tmpl index bc1cfda..7517726 100644 --- a/cmd/cli/templates/backend/main.go.tmpl +++ b/cmd/cli/templates/backend/main.go.tmpl @@ -1,7 +1,7 @@ package main import ( - "context" + "context" "log" "net" "net/http" @@ -9,40 +9,63 @@ import ( "time" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" - "github.com/payne8/go-libsql-dual-driver" "github.com/rs/cors" - sqlite "github.com/ytsruh/gorm-libsql" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/reflection" "gorm.io/gorm" - pb "{{ .AppName }}/gen/go" + pb "{{ .AppName }}/gen/go" + + // the following line is used for the local database + "gorm.io/driver/sqlite" + // uncomment the following lines to switch to a production ready remote database + //libsqldb "github.com/payne8/go-libsql-dual-driver" + //sqlite "github.com/ytsruh/gorm-libsql" ) func main() { logger := log.New(os.Stdout, "{{ .AppName }} ", log.LstdFlags) - primaryUrl := os.Getenv("LIBSQL_DATABASE_URL") - authToken := os.Getenv("LIBSQL_AUTH_TOKEN") - tdb, err := libsqldb.NewLibSqlDB( - primaryUrl, - // libsqldb.WithMigrationFiles(migrationFiles), - libsqldb.WithAuthToken(authToken), - libsqldb.WithLocalDBName("local.db"), // will not be used for remote-only - ) + // instantiate the grom ORM + + /* + uncomment the following code to switch to a production ready remote database + you will need to set the environment variables + */ + + // --------------------- start of remote database code --------------------- + //primaryUrl := os.Getenv("LIBSQL_DATABASE_URL") + //authToken := os.Getenv("LIBSQL_AUTH_TOKEN") + + //tdb, err := libsqldb.NewLibSqlDB( + // primaryUrl, + // //libsqldb.WithMigrationFiles(migrationFiles), + // libsqldb.WithAuthToken(authToken), + // libsqldb.WithLocalDBName("local.db"), // will not be used for remote-only + //) + //if err != nil { + // logger.Printf("failed to open db %s: %s", primaryUrl, err) + // log.Fatalln(err) + // return + //} + + //gormDB, err := gorm.Open(sqlite.New(sqlite.Config{Conn: tdb.DB}), &gorm.Config{}) + //if err != nil { + // logger.Printf("failed to open gorm db %s: %s", primaryUrl, err) + // log.Fatalln(err) + // return + //} + + // --------------------- end of remote database code --------------------- + + // -- start of local database code -- + gormDB, err := gorm.Open(sqlite.Open("local.db"), &gorm.Config{}) if err != nil { - logger.Printf("failed to open db %s: %s", primaryUrl, err) - log.Fatalln(err) - return - } - - // Instantiate the gorm ORM - gormDB, err := gorm.Open(sqlite.New(sqlite.Config{Conn: tdb.DB}), &gorm.Config{}) - if err != nil { - logger.Printf("failed to open gorm db %s: %s", primaryUrl, err) + logger.Printf("failed to open gorm db: %s", err) log.Fatalln(err) return } + // -- end of local database code -- // Uncomment these lines if you need automatic migration // err = gormDB.AutoMigrate(&pb.UserORM{}) diff --git a/vue-gen/vue-gen.go b/vue-gen/vue-gen.go new file mode 100644 index 0000000..aa53dab --- /dev/null +++ b/vue-gen/vue-gen.go @@ -0,0 +1,409 @@ +package vue_gen + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" +) + +// ----- Structures for swagger file parsing ----- + +// Swagger holds both Definitions and Paths. +type Swagger struct { + Definitions map[string]Definition `json:"definitions"` + Paths map[string]map[string]OperationObject `json:"paths"` +} + +// Definition represents a swagger definition. +type Definition struct { + Type string `json:"type"` + Properties map[string]Property `json:"properties"` + Required []string `json:"required"` +} + +// Property represents one field of a swagger definition. +type Property struct { + Type string `json:"type,omitempty"` + Format string `json:"format,omitempty"` + Description string `json:"description,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Enum []string `json:"enum,omitempty"` + Ref string `json:"$ref,omitempty"` + Items *Property `json:"items,omitempty"` + Properties map[string]Property `json:"properties,omitempty"` // For inline object definitions. +} + +// OperationObject represents an operation (like get, post, put, etc.) from swagger Paths. +type OperationObject struct { + OperationID string `json:"operationId"` + Parameters []Parameter `json:"parameters,omitempty"` +} + +// Parameter represents a swagger parameter. We are interested in "body" parameters. +type Parameter struct { + In string `json:"in"` + Name string `json:"name"` + Schema *SchemaObject `json:"schema,omitempty"` +} + +// SchemaObject represents a schema reference. +type SchemaObject struct { + Ref string `json:"$ref,omitempty"` +} + +// OperationInfo is used to store the service call information for a given request payload. +type OperationInfo struct { + ServiceName string // e.g., "PeachService" + MethodName string // e.g., "peachListProducts" + HTTPMethod string // e.g., "post", "put", etc. +} + +// ----- Structures for Vue component generation ----- + +type FormInput struct { + ID string // id in the Vue interface + Label string // label shown to the user + Type string // the input type, e.g., text, number, select, checkbox, etc. + DefaultValue string // optional default value (as string) + Placeholder string // optional placeholder + Required bool // required field? + Min string // string representation of minimum + Max string // string representation of maximum + Options []Option // for select or similar +} + +type Option struct { + Label string // option label + Value string // option value +} + +// VueComponentData holds the data passed to our Vue template. +type VueComponentData struct { + // ComponentName is the request definition name with "Form" appended (capital F). + ComponentName string + // DefaultFormTitle is used if no formTitle prop is provided. + DefaultFormTitle string + SubmitLabel string // label for the submit button + FormInputs []FormInput // form inputs computed from the schema + + // Operation properties (if found) + OperationFound bool + ServiceName string // e.g., "PeachService" + ServiceMethod string // e.g., "peachListProducts" + OperationHTTPMethod string // e.g., "post", "put", etc. +} + +func GenVueFromSwagger(swaggerPath string, outDir string) error { + // Read the swagger JSON file. + data, err := os.ReadFile(swaggerPath) + if err != nil { + return fmt.Errorf("error reading swagger file: %v", err) + } + + // Unmarshal the swagger file. + var swagger Swagger + if err := json.Unmarshal(data, &swagger); err != nil { + return fmt.Errorf("Error parsing swagger JSON: %v\n", err) + } + + // Build a mapping from definition name (used in a body parameter) to OperationInfo. + operationMap := buildOperationMap(swagger.Paths) + + // Ensure output directory exists. + if err := os.MkdirAll(outDir, os.ModePerm); err != nil { + return fmt.Errorf("Error creating output directory: %v\n", err) + } + + // Generate forms only for definitions used as payload (i.e., in the operationMap). + for defName, def := range swagger.Definitions { + // Skip definitions that are not used as request bodies. + opInfo, ok := operationMap[defName] + if !ok { + continue + } + + componentName := defName + "Form" // Append "Form" with a capital F. + componentData := VueComponentData{ + ComponentName: componentName, + DefaultFormTitle: defName, + SubmitLabel: "Submit", + FormInputs: []FormInput{}, + OperationFound: true, + ServiceName: opInfo.ServiceName, + ServiceMethod: opInfo.MethodName, + OperationHTTPMethod: opInfo.HTTPMethod, + } + + // Get the schema to use for the form. + // If a 'payload' property exists, unwrap it. + schemaForForm := getFormSchema(def, swagger.Definitions) + + // Process each property from the chosen schema. + for propName, prop := range schemaForForm { + // Only skip the "id" field for create requests (which we assume use the POST method). + if strings.ToLower(propName) == "id" && strings.ToLower(componentData.OperationHTTPMethod) == "post" { + continue + } + input := FormInput{ + ID: propName, + Label: labelFromProperty(prop, propName), + Placeholder: "", + Required: isRequired(propName, def.Required), + } + + // Set the proper type and constraints. + if prop.Ref != "" { + refParts := strings.Split(prop.Ref, "/") + refName := refParts[len(refParts)-1] + input.Type = "text" + input.Placeholder = "Reference: " + refName + } else if len(prop.Enum) > 0 { + input.Type = "select" + for _, enumVal := range prop.Enum { + input.Options = append(input.Options, Option{ + Label: enumVal, + Value: enumVal, + }) + } + } else if prop.Type == "string" { + input.Type = "text" + if prop.Format == "date" { + input.Type = "date" + } else if prop.Format == "email" { + input.Type = "email" + } + } else if prop.Type == "number" || prop.Type == "integer" { + input.Type = "number" + if prop.Minimum != nil { + input.Min = strconv.FormatFloat(*prop.Minimum, 'f', -1, 64) + } + if prop.Maximum != nil { + input.Max = strconv.FormatFloat(*prop.Maximum, 'f', -1, 64) + } + } else if prop.Type == "boolean" { + input.Type = "checkbox" + } else if prop.Type == "array" && prop.Items != nil { + if len(prop.Items.Enum) > 0 { + input.Type = "select" + for _, enumVal := range prop.Items.Enum { + input.Options = append(input.Options, Option{ + Label: enumVal, + Value: enumVal, + }) + } + } else { + input.Type = "text" + input.Placeholder = "Comma separated values" + } + } else { + input.Type = "text" + } + + componentData.FormInputs = append(componentData.FormInputs, input) + } + + // Render the Vue component. + if err := renderVueComponent(componentData, outDir); err != nil { + return fmt.Errorf("Error generating component for %s: %v\n", defName, err) + } else { + fmt.Printf("Successfully generated request form component for %s\n", componentName) + } + } + return nil +} + +// getFormSchema checks if the given definition has a 'payload' property. +// If present and the payload property either has inline properties or a $ref, +// it returns the property map from that object. Otherwise, it returns the top-level properties. +func getFormSchema(def Definition, allDefs map[string]Definition) map[string]Property { + if payloadProp, exists := def.Properties["payload"]; exists { + if len(payloadProp.Properties) > 0 { + return payloadProp.Properties + } else if payloadProp.Ref != "" { + refParts := strings.Split(payloadProp.Ref, "/") + refName := refParts[len(refParts)-1] + if refDef, ok := allDefs[refName]; ok { + return refDef.Properties + } + } + } + return def.Properties +} + +// buildOperationMap scans swagger paths and returns a mapping from the referenced definition +// (in a body parameter) to an OperationInfo that holds service call details. +// It considers the HTTP verb (key) to help decide whether a request is a create request. +func buildOperationMap(paths map[string]map[string]OperationObject) map[string]OperationInfo { + opMap := make(map[string]OperationInfo) + for _, methods := range paths { + for httpMethod, op := range methods { + // Look for a "body" parameter. + for _, param := range op.Parameters { + if strings.ToLower(param.In) == "body" && param.Schema != nil && param.Schema.Ref != "" { + // Get the definition name referenced in the schema. + refParts := strings.Split(param.Schema.Ref, "/") + defName := refParts[len(refParts)-1] + if op.OperationID != "" { + // Assume operationID is something like "Peach_ListProducts" for requests. + parts := strings.Split(op.OperationID, "_") + serviceName := "" + methodName := "" + if len(parts) >= 2 { + serviceName = parts[0] + "Service" + // Remove underscores and lower-case the first letter for method name. + noUnderscore := strings.ReplaceAll(op.OperationID, "_", "") + methodName = lowerFirst(noUnderscore) + } + if serviceName != "" && methodName != "" { + opMap[defName] = OperationInfo{ + ServiceName: serviceName, + MethodName: methodName, + HTTPMethod: strings.ToLower(httpMethod), + } + } + } + } + } + } + } + return opMap +} + +// lowerFirst lower-cases the first letter of the given string. +func lowerFirst(s string) string { + if len(s) == 0 { + return s + } + return strings.ToLower(s[:1]) + s[1:] +} + +// labelFromProperty returns a label derived from the property's description or capitalizes the property name. +func labelFromProperty(prop Property, propName string) string { + if prop.Description != "" { + return prop.Description + } + return strings.ToUpper(propName[:1]) + propName[1:] +} + +// isRequired checks whether the given property is among the required fields. +func isRequired(propName string, required []string) bool { + for _, r := range required { + if r == propName { + return true + } + } + return false +} + +// renderVueComponent uses a Go text/template to render a Vue component file. +func renderVueComponent(data VueComponentData, outDir string) error { + // The template now: + // • Imports DynamicForm from "@masonitestudios/dynamic-vue" + // • Declares emits and calls emit('send', payload) on a successful network call. + // • Wraps the form values under a "payload" key. + // • Accepts a new prop, initialValues, and maps those into each FormInput's defaultValue. + const vueTemplate = ` + + +` + // Create a template with a helper to add one (for indexing). + tmpl := template.New("vueComponent").Funcs(template.FuncMap{ + "add1": func(i int) int { return i + 1 }, + }) + tmpl, err := tmpl.Parse(vueTemplate) + if err != nil { + return err + } + + filename := filepath.Join(outDir, data.ComponentName+".vue") + outFile, err := os.Create(filename) + if err != nil { + return err + } + defer outFile.Close() + + return tmpl.Execute(outFile, data) +}