//go:build scripts package scaffold import ( "fmt" "go/ast" "go/parser" "os" "path/filepath" "regexp" "slices" "strings" "text/template" "github.com/go-openapi/swag" "golang.org/x/mod/modfile" ) // Scaffolding tool to auto-generate basic CRUD handlers for a given database model. type FieldType struct { Name string } type Field struct { Name string Type FieldType } type StorageResource struct { Name string Fields []Field } func ParseModel(path string) (*StorageResource, error) { content, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read model file: %w", err) } regex, err := regexp.Compile(`type ([^\s]+) (struct {[^}]+})`) if err != nil { return nil, fmt.Errorf("failed to compile model expression regex: %w", err) } matches := regex.FindStringSubmatch(string(content)) resourceName := matches[1] expression := matches[2] node, err := parser.ParseExpr(expression) if err != nil { return nil, fmt.Errorf("failed to parse model expression: %w", err) } structType, ok := node.(*ast.StructType) if !ok { return nil, fmt.Errorf("%s is not a struct definition", expression) } fields := []Field{} for _, field := range structType.Fields.List { name := field.Names[0].Name fieldType := expression[field.Type.Pos()-1 : field.Type.End()-1] field := Field{ Name: name, Type: FieldType{ Name: fieldType, }, } if field.Name == "R" || field.Name == "L" { break } fields = append(fields, field) } resource := StorageResource{ Name: resourceName, Fields: fields, } return &resource, nil } type Property struct { Name string Type string Required bool Format *string } type SwaggerResource struct { Name string URLName string Properties []Property PayloadProperties []Property } func GenerateSwagger(resource *StorageResource, outputPath string, force bool) error { definitionsPath := filepath.Join(outputPath, "definitions") pathsPath := filepath.Join(outputPath, "paths") if err := createDirIfAbsent(definitionsPath); err != nil { return err } if err := createDirIfAbsent(pathsPath); err != nil { return err } swaggerResource := toSwaggerResource(resource) definitionsSpecPath := filepath.Join(definitionsPath, swaggerResource.URLName+".yml") pathsSpecPath := filepath.Join(pathsPath, swaggerResource.URLName+".yml") if err := executeTemplate(swaggerDefinitionsTemplate, definitionsSpecPath, swaggerResource, force); err != nil { return err } return executeTemplate(swaggerPathsTemplate, pathsSpecPath, swaggerResource, force) } var payloadExcluded = []string{"ID", "CreatedAt", "UpdatedAt"} func toSwaggerResource(resource *StorageResource) *SwaggerResource { properties := make([]Property, 0, len(resource.Fields)) payloadProperties := make([]Property, 0, len(resource.Fields)) for _, field := range resource.Fields { property := fieldToProperty(field) properties = append(properties, property) if !slices.Contains(payloadExcluded, field.Name) { payloadProperties = append(payloadProperties, property) } } swaggerResource := SwaggerResource{ Name: resource.Name, URLName: strings.ToLower(resource.Name), // TODO: Use dash separator Properties: properties, PayloadProperties: payloadProperties, } return &swaggerResource } const ( propertyTypeString = "string" propertyTypeInteger = "integer" propertyTypeBoolean = "boolean" propertyTypeNumber = "number" propertyTypeDateTime = "date-time" propertyTypeUUID4 = "uuid4" ) func fieldToProperty(field Field) Property { propertyType := propertyTypeString // Fallback type required := true var format *string switch field.Type.Name { case "int": propertyType = propertyTypeInteger case "null.Int": propertyType = propertyTypeInteger required = false case "bool": propertyType = propertyTypeBoolean case "null.Bool": propertyType = propertyTypeBoolean required = false case "null.String": propertyType = propertyTypeString required = false case "types.Decimal": propertyType = propertyTypeNumber case "types.NullDecimal": propertyType = propertyTypeNumber required = false case "time.Time": format = swag.String(propertyTypeDateTime) case "null.Time": format = swag.String(propertyTypeDateTime) required = false } if strings.Contains(field.Name, "ID") { format = swag.String(propertyTypeUUID4) } return Property{ Name: goToJSNaming(field.Name), Type: propertyType, Required: required, Format: format, } } type HandlerField struct { Name string Value string PlaceholderValue string } type HandlerResource struct { Name string Fields []HandlerField } type Handler struct { Module string Package string Resource *HandlerResource } func toHandlerResource(storageResource *StorageResource, swaggerResource *SwaggerResource) *HandlerResource { fields := make([]HandlerField, len(swaggerResource.Properties)) for i, property := range swaggerResource.Properties { fields[i] = propertyToHandlerField(property) // Hack to get the proper field name. fields[i].Name = storageResource.Fields[i].Name } return &HandlerResource{ Name: swaggerResource.Name, Fields: fields, } } func propertyToHandlerField(property Property) HandlerField { placeholderValue := `swag.String("` + property.Name + `")` // Fallback placeholder switch property.Type { case "integer": placeholderValue = `swag.Int64(100)` case "boolean": placeholderValue = `swag.Bool(true)` case "number": placeholderValue = `swag.Float64(10.0)` } if property.Format != nil { switch *property.Format { case "date-time": placeholderValue = `conv.DateTime(strfmt.DateTime(time.Now()))` case "uuid4": placeholderValue = `conv.UUID4(strfmt.UUID4("1606388b-1f88-4f56-bd97-c27fbc3fe080"))` } } return HandlerField{ Name: property.Name, PlaceholderValue: placeholderValue, } } type handlerConfig struct { filePrefix string fileSuffix string template string } var configuredHandlers = map[string]handlerConfig{ "get-all": {"get_", "_list.go", getListHandlerTemplate}, "get": {"get_", ".go", getHandlerTemplate}, "post": {"post_", ".go", postHandlerTemplate}, "put": {"put_", ".go", putHandlerTemplate}, "delete": {"delete_", ".go", deleteHandlerTemplate}, } func GenerateHandlers(resource *StorageResource, handlerBaseDir, modulePath string, methods []string, force bool) error { packageName := strings.ToLower(resource.Name) resourceBaseDir := filepath.Join(handlerBaseDir, packageName) if _, err := os.Stat(resourceBaseDir); os.IsNotExist(err) { if err := os.Mkdir(resourceBaseDir, 0755); err != nil { return fmt.Errorf("failed to create resource base directory: %w", err) } } module, err := getModuleName(modulePath) if err != nil { return err } handler := Handler{ Module: module, Package: packageName, Resource: toHandlerResource(resource, toSwaggerResource(resource)), } for _, method := range methods { handlerConfig, ok := configuredHandlers[method] if !ok { return fmt.Errorf("unsupported method: %s", method) } outputPath := filepath.Join(resourceBaseDir, handlerConfig.filePrefix+packageName+handlerConfig.fileSuffix) if err := executeTemplate(handlerConfig.template, outputPath, handler, force); err != nil { return err } } return nil } func fileExists(path string) bool { _, err := os.Stat(path) return !os.IsNotExist(err) } func createDirIfAbsent(path string) error { if !fileExists(path) { if err := os.MkdirAll(path, 0755); err != nil { return fmt.Errorf("failed to create directory '%s': %w", path, err) } } return nil } func executeTemplate(templateStr, outputPath string, data interface{}, force bool) error { templ := template.Template{} if _, err := templ.Parse(templateStr); err != nil { return fmt.Errorf("failed to parse template: %w", err) } if !force && fileExists(outputPath) { return fmt.Errorf("file '%s' already exists; call with --force to overwrite", outputPath) } file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { return fmt.Errorf("failed to open file '%s': %w", outputPath, err) } defer file.Close() if err := templ.Execute(file, data); err != nil { return fmt.Errorf("failed to execute template: %w", err) } return nil } func goToJSNaming(name string) string { // Hack to properly handle ID. switch name { case "ID": return "id" default: first := strings.ToLower(name[0:1]) return first + name[1:] } } func getModuleName(absolutePathToGoMod string) (string, error) { dat, err := os.ReadFile(absolutePathToGoMod) if err != nil { return "", fmt.Errorf("failed to read go.mod: %w", err) } mod := modfile.ModulePath(dat) return mod, nil }