Files
Gumble-Backend/scripts/internal/scaffold/scaffold.go
Zane Walker 7e940c83a7
Some checks failed
Build & Test / build-test (push) Has been cancelled
Build & Test / swagger-codegen-cli (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
(Feat): Initial Commit
2026-07-03 19:41:31 +05:30

368 lines
8.7 KiB
Go

//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
}