diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 50c5b36..1f58eb3 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -225,14 +225,14 @@ func generateCmd() *cli.Command { return fmt.Errorf("error generating typescript code | %w", err) } - // make sure src/generated-components exists - err = os.Mkdir("src/generated-components", 0755) + // make sure src/generated-sample-components exists + err = os.Mkdir("src/generated-sample-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") + err = vue_gen.GenVueFromSwagger("../gen/openapi/proto/service.swagger.json", "src/generated-sample-components") if err != nil { return fmt.Errorf("error generating vue components | %w", err) } @@ -249,8 +249,6 @@ func generateCmd() *cli.Command { } } - // TODO: update typescript code gen to use this command `npx openapi-typescript-codegen --input ../gen/openapi/proto/service.swagger.json --output src/generated/ts-client --client fetch` - return nil }, } diff --git a/cmd/cli/templates/proto/application.proto.tmpl b/cmd/cli/templates/proto/application.proto.tmpl index 666599a..aafd764 100644 --- a/cmd/cli/templates/proto/application.proto.tmpl +++ b/cmd/cli/templates/proto/application.proto.tmpl @@ -9,6 +9,15 @@ import "google/api/annotations.proto"; option go_package = "./;pb"; +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Your API Title" + version: "v1.0" + description: "Your API description" + } + host: "localhost:8080" // Set the server host +}; + service {{ .AppNameCaps }} { option (gorm.server).autogen = true; // Add your service methods here diff --git a/vue-gen/vue-gen.go b/vue-gen/vue-gen.go index aa53dab..a1176a4 100644 --- a/vue-gen/vue-gen.go +++ b/vue-gen/vue-gen.go @@ -11,21 +11,21 @@ import ( ) // ----- Structures for swagger file parsing ----- +// +// Note: In order to support list response scanning we add a Responses field +// to OperationObject and a new ResponseObject type. -// 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"` @@ -35,46 +35,48 @@ type Property struct { 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. + Properties map[string]Property `json:"properties,omitempty"` // For inline 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"` + OperationID string `json:"operationId"` + Parameters []Parameter `json:"parameters,omitempty"` + Responses map[string]ResponseObject `json:"responses,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 ResponseObject struct { + Schema *SchemaObject `json:"schema,omitempty"` +} + +// OperationInfo is used when generating a form component. 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 ----- +// ----- Structures for Vue form 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. + Type string // e.g., text, number, select, checkbox, etc. DefaultValue string // optional default value (as string) Placeholder string // optional placeholder - Required bool // required field? + Required bool // required flag Min string // string representation of minimum Max string // string representation of maximum - Options []Option // for select or similar + Options []Option // for select fields } type Option struct { @@ -82,22 +84,44 @@ type Option struct { 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) + ComponentName string + DefaultFormTitle string + SubmitLabel string + FormInputs []FormInput OperationFound bool ServiceName string // e.g., "PeachService" ServiceMethod string // e.g., "peachListProducts" OperationHTTPMethod string // e.g., "post", "put", etc. } +// ----- Structures for Vue Table generation ----- + +// TableColumn type maps to your supported TS interface. +type VueTableColumn struct { + ID string + Label string + Type string // e.g., "text", "email", "number", etc. +} + +type VueTableData struct { + ComponentName string + DefaultTableTitle string + OperationFound bool + ServiceName string // e.g., "PeachService" + ServiceMethod string // e.g., "peachListProducts" + TableColumns []VueTableColumn +} + +// ----- Main Generation Function ----- + +// GenVueFromSwagger reads the swagger spec and generates: +// - Form components for definitions used as request bodies, +// - Table components for list operations (matching the pattern Service_ListXYZ). +// +// For table components the generator dynamically creates the column definitions based on +// the response definition that is expected to have a "results" property referencing +// another definition. func GenVueFromSwagger(swaggerPath string, outDir string) error { // Read the swagger JSON file. data, err := os.ReadFile(swaggerPath) @@ -119,15 +143,14 @@ func GenVueFromSwagger(swaggerPath string, outDir string) error { return fmt.Errorf("Error creating output directory: %v\n", err) } - // Generate forms only for definitions used as payload (i.e., in the operationMap). + // ----- Generate Form Components for Request Payloads ----- 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. + componentName := defName + "Form" componentData := VueComponentData{ ComponentName: componentName, DefaultFormTitle: defName, @@ -139,16 +162,14 @@ func GenVueFromSwagger(swaggerPath string, outDir string) error { 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). + // Skip "id" for create requests (assuming POST). if strings.ToLower(propName) == "id" && strings.ToLower(componentData.OperationHTTPMethod) == "post" { continue } + input := FormInput{ ID: propName, Label: labelFromProperty(prop, propName), @@ -156,7 +177,6 @@ func GenVueFromSwagger(swaggerPath string, outDir string) error { Required: isRequired(propName, def.Required), } - // Set the proper type and constraints. if prop.Ref != "" { refParts := strings.Split(prop.Ref, "/") refName := refParts[len(refParts)-1] @@ -207,19 +227,66 @@ func GenVueFromSwagger(swaggerPath string, outDir string) error { 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) } } + + // ----- Generate Table Components for List Operations ----- + listOpsGenerated := make(map[string]bool) + for _, methods := range swagger.Paths { + for _, op := range methods { + if op.OperationID == "" { + continue + } + // Check for operations using the _List pattern. + if strings.Contains(op.OperationID, "_List") { + if listOpsGenerated[op.OperationID] { + continue + } + listOpsGenerated[op.OperationID] = true + + parts := strings.Split(op.OperationID, "_") + if len(parts) != 2 || !strings.HasPrefix(parts[1], "List") { + continue + } + + serviceName := parts[0] + "Service" + serviceMethod := lowerFirst(strings.ReplaceAll(op.OperationID, "_", "")) + resourceName := parts[1][len("List"):] // e.g., "Products" + tableTitle := resourceName + " List" + componentName := op.OperationID + "Table" + + // Instead of using the resourceName or definition name, we build table columns by + // looking at the response. We expect the response to have a "results" property that + // either directly references or is an array of a definition. + tableColumns := getResponseTableColumns(op, swagger.Definitions) + + tableData := VueTableData{ + ComponentName: componentName, + DefaultTableTitle: tableTitle, + OperationFound: true, + ServiceName: serviceName, + ServiceMethod: serviceMethod, + TableColumns: tableColumns, + } + + if err := renderVueTableComponent(tableData, outDir); err != nil { + return fmt.Errorf("Error generating table component for %s: %v\n", op.OperationID, err) + } else { + fmt.Printf("Successfully generated table 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. +// getFormSchema checks whether a "payload" property exists. +// If so, it will return its nested properties; otherwise, 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 { @@ -235,27 +302,110 @@ func getFormSchema(def Definition, allDefs map[string]Definition) map[string]Pro 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. +// getResponseTableColumns builds the table columns based on the response schema for a list operation. +// It looks for the 200 response, then expects its schema to reference a definition that has a "results" property. +// That property should either directly reference another definition (or, if an array, have items referencing one). +func getResponseTableColumns(op OperationObject, allDefs map[string]Definition) []VueTableColumn { + resp, ok := op.Responses["200"] + if !ok || resp.Schema == nil { + return defaultColumns() + } + // Get the definition of the response. + respDef := getDefinitionFromSchema(resp.Schema, allDefs) + if respDef == nil { + return defaultColumns() + } + + resultsProp, exists := respDef.Properties["results"] + if !exists { + return defaultColumns() + } + + var targetDef *Definition + // Either the results property has a direct reference… + if resultsProp.Ref != "" { + if def, ok := allDefs[getRefName(resultsProp.Ref)]; ok { + targetDef = &def + } + // … or it is an array whose items reference a definition. + } else if resultsProp.Type == "array" && resultsProp.Items != nil && resultsProp.Items.Ref != "" { + if def, ok := allDefs[getRefName(resultsProp.Items.Ref)]; ok { + targetDef = &def + } + } + + if targetDef == nil { + return defaultColumns() + } + + // Build table columns from the target definition's properties. + var cols []VueTableColumn + for propName, prop := range targetDef.Properties { + colType := "text" + if prop.Type == "number" || prop.Type == "integer" { + colType = "number" + } else if prop.Type == "boolean" { + colType = "checkbox" + } else if prop.Type == "string" { + colType = "text" + if prop.Format == "date" { + colType = "date" + } else if prop.Format == "email" { + colType = "email" + } + } + cols = append(cols, VueTableColumn{ + ID: propName, + Label: strings.Title(propName), + Type: colType, + }) + } + if len(cols) == 0 { + return defaultColumns() + } + return cols +} + +func defaultColumns() []VueTableColumn { + return []VueTableColumn{ + {ID: "id", Label: "ID", Type: "text"}, + } +} + +// getDefinitionFromSchema retrieves a definition given a SchemaObject with a $ref. +func getDefinitionFromSchema(schema *SchemaObject, allDefs map[string]Definition) *Definition { + if schema == nil || schema.Ref == "" { + return nil + } + refName := getRefName(schema.Ref) + if def, ok := allDefs[refName]; ok { + return &def + } + return nil +} + +// getRefName extracts the definition name from a reference, e.g., "#/definitions/PeachProduct" -> "PeachProduct". +func getRefName(ref string) string { + parts := strings.Split(ref, "/") + return parts[len(parts)-1] +} + +// buildOperationMap scans swagger paths and returns a mapping from the definition referenced in a body parameter +// to an OperationInfo. 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) } @@ -274,7 +424,6 @@ func buildOperationMap(paths map[string]map[string]OperationObject) map[string]O return opMap } -// lowerFirst lower-cases the first letter of the given string. func lowerFirst(s string) string { if len(s) == 0 { return s @@ -282,7 +431,6 @@ func lowerFirst(s string) string { 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 @@ -290,7 +438,6 @@ func labelFromProperty(prop Property, propName string) string { 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 { @@ -300,13 +447,9 @@ func isRequired(propName string, required []string) bool { return false } -// renderVueComponent uses a Go text/template to render a Vue component file. +// ----- Rendering Templates ----- + 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 = `