(Feat): Initial Commit
This commit is contained in:
367
scripts/internal/scaffold/scaffold.go
Normal file
367
scripts/internal/scaffold/scaffold.go
Normal file
@@ -0,0 +1,367 @@
|
||||
//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
|
||||
}
|
||||
Reference in New Issue
Block a user