Files
go-omni/cmd/go-omni/scaffold/scaffold.go

330 lines
11 KiB
Go

package scaffold
import (
"embed"
"errors"
"fmt"
"go/format"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"text/template"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)
//go:embed templates moduleTemplates
var templateFiles embed.FS
// Options controls project generation.
type Options struct {
Dir string
Module string
FrameworkDir string
FrameworkVersion string
Database string
LogOutput string
LogFormat string
Storage string
Force bool
}
var files = map[string]string{
"internal/settings/config.go": "templates/modules-settings.go.tmpl",
"internal/settings/config_test.go": "templates/modules-settings_test.go.tmpl",
"etc/config.yaml": "templates/modules-config.yaml.tmpl",
"go.mod": "templates/go.mod.tmpl",
"README.md": "templates/README.md.tmpl",
"cmd/server/main.go": "templates/modules-main.go.tmpl",
"internal/model/user.go": "templates/internal-model-user.go.tmpl",
"internal/model/errors.go": "templates/internal-model-errors.go.tmpl",
"internal/repository/users.go": "templates/internal-repository-users.go.tmpl",
"internal/repository/memory.go": "templates/internal-repository-memory.go.tmpl",
"internal/servicecontext/context.go": "templates/internal-servicecontext-context.go.tmpl",
"internal/service/user.go": "templates/internal-service-user.go.tmpl",
"internal/handler/user.go": "templates/internal-handler-user.go.tmpl",
}
// Generate creates a GoOmni service skeleton.
func Generate(options Options) error {
if strings.TrimSpace(options.Dir) == "" {
return errors.New("project directory is required")
}
if strings.TrimSpace(options.Module) == "" {
return errors.New("module path is required")
}
if err := module.CheckImportPath(options.Module); err != nil {
return fmt.Errorf("invalid module path: %w", err)
}
modules := options.LogOutput != "" || options.LogFormat != "" || options.Storage != ""
logOutput, logFormat, storage := options.LogOutput, options.LogFormat, options.Storage
if logOutput == "" {
logOutput = "stderr"
}
if logFormat == "" {
logFormat = "json"
}
if storage == "" {
storage = "none"
}
if logOutput != "stdout" && logOutput != "stderr" && logOutput != "file" {
return fmt.Errorf("unsupported log output %q: choose stdout, stderr or file", logOutput)
}
if logFormat != "json" && logFormat != "text" {
return fmt.Errorf("unsupported log format %q: choose json or text", logFormat)
}
switch storage {
case "none", "local", "s3", "r2", "oss":
default:
return fmt.Errorf("unsupported object storage %q: choose none, local, s3, r2 or oss", storage)
}
database := strings.ToLower(strings.TrimSpace(options.Database))
if database == "" {
database = "memory"
}
if database != "memory" && database != "mysql" {
return fmt.Errorf("unsupported database %q: choose memory or mysql", database)
}
version := strings.TrimSpace(options.FrameworkVersion)
if version == "" {
version = currentFrameworkVersion()
if modules && version == defaultFrameworkVersion {
version = modulesFrameworkVersion
}
}
if !validFrameworkVersion(version) {
return fmt.Errorf("framework version %q must be a complete v0 or v1 semantic version", version)
}
directory, err := filepath.Abs(options.Dir)
if err != nil {
return fmt.Errorf("resolve project directory: %w", err)
}
if err := checkTarget(directory, options.Force); err != nil {
return err
}
// An explicit remote version opts out of automatic local development mode.
replace := ""
if options.FrameworkDir != "" || options.FrameworkVersion == "" {
replace = frameworkReplace(directory, options.FrameworkDir)
}
if replace == "" && semver.Compare(version, modulesFrameworkVersion) < 0 && version != installedFrameworkVersion() {
return fmt.Errorf("module templates require %s or newer; use --framework-dir for unpublished source", modulesFrameworkVersion)
}
data := templateData{
Module: options.Module,
FrameworkVersion: version,
FrameworkReplace: replace,
Database: database,
Modules: modules, LogOutput: logOutput, LogFormat: logFormat, Storage: storage,
}
projectFiles := make(map[string]string, len(files)+3)
for path, source := range files {
projectFiles[path] = source
}
if database == "mysql" {
projectFiles["internal/repository/mysql.go"] = "templates/internal-repository-mysql.go.tmpl"
projectFiles["schema.sql"] = "templates/schema.sql.tmpl"
projectFiles["internal/servicecontext/databaseIntegration_test.go"] = "templates/databaseIntegration_test.go.tmpl"
}
if modules {
projectFiles["cmd/server/main.go"] = "templates/modules-main.go.tmpl"
projectFiles["internal/settings/config.go"] = "templates/modules-settings.go.tmpl"
projectFiles["internal/settings/config_test.go"] = "templates/modules-settings_test.go.tmpl"
projectFiles["etc/config.yaml"] = "templates/modules-config.yaml.tmpl"
projectFiles[".gitignore"] = "templates/modules-gitignore.tmpl"
projectFiles["MODULES.md"] = "templates/modules-README.md.tmpl"
if storage != "none" {
projectFiles["internal/servicecontext/storage.go"] = "templates/modules-storage.go.tmpl"
projectFiles["internal/servicecontext/storage_test.go"] = "templates/modules-storage_test.go.tmpl"
projectFiles["internal/service/files.go"] = "templates/modules-filesService.go.tmpl"
projectFiles["internal/handler/files.go"] = "templates/modules-filesHandler.go.tmpl"
projectFiles["internal/handler/files_test.go"] = "templates/modules-filesHandler_test.go.tmpl"
}
}
if options.Force {
// Do not leave old generated Go files that reference dependencies omitted
// by this profile, and do not silently delete potentially edited code.
for _, path := range []string{"internal/repository/mysql.go", "internal/servicecontext/databaseIntegration_test.go", "schema.sql", "internal/settings/config.go", "internal/settings/config_test.go", "etc/config.yaml", "MODULES.md", "internal/servicecontext/storage.go", "internal/servicecontext/storage_test.go", "internal/service/files.go", "internal/handler/files.go", "internal/handler/files_test.go"} {
if _, emitted := projectFiles[path]; emitted {
continue
}
if _, err := os.Lstat(filepath.Join(directory, path)); err == nil {
return fmt.Errorf("existing %s conflicts with the selected profile; generate into a new directory instead of removing existing code", path)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
}
}
paths := make([]string, 0, len(projectFiles))
for path := range projectFiles {
paths = append(paths, path)
}
sort.Strings(paths)
// Render and format the whole project before touching the target directory.
contents := make(map[string]string, len(paths))
for _, path := range paths {
content, err := render(projectFiles[path], data)
if err != nil {
return fmt.Errorf("render %s: %w", path, err)
}
if strings.HasSuffix(path, ".go") {
formatted, err := format.Source([]byte(content))
if err != nil {
return fmt.Errorf("format %s: %w", path, err)
}
content = string(formatted)
}
contents[path] = content
}
for _, path := range paths {
target := filepath.Join(directory, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create %s: %w", filepath.Dir(path), err)
}
if !options.Force {
if _, err := os.Stat(target); err == nil {
return fmt.Errorf("file already exists: %s", target)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("check %s: %w", target, err)
}
}
if err := os.WriteFile(target, []byte(contents[path]), 0o644); err != nil {
return fmt.Errorf("write %s: %w", target, err)
}
}
return nil
}
type templateData struct {
API bool
Module string
FrameworkVersion string
FrameworkReplace string
Database string
Modules bool
LogOutput string
LogFormat string
Storage string
}
func render(name string, data templateData) (string, error) {
content, err := fs.ReadFile(templateFiles, name)
if err != nil {
return "", err
}
tmpl, err := newTemplate(name, string(content))
if err != nil {
return "", err
}
var output strings.Builder
if err := tmpl.Execute(&output, data); err != nil {
return "", err
}
return output.String(), nil
}
func newTemplate(name, content string) (*template.Template, error) {
return template.New(name).Parse(content)
}
func checkTarget(directory string, force bool) error {
info, err := os.Stat(directory)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("check project directory: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("project path is not a directory: %s", directory)
}
if force {
return nil
}
entries, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("read project directory: %w", err)
}
if len(entries) > 0 {
return fmt.Errorf("project directory is not empty: %s (use --force to overwrite generated files)", directory)
}
return nil
}
func frameworkReplace(projectDir, configured string) string {
root := strings.TrimSpace(configured)
if root == "" {
root = findFrameworkRoot()
}
if root == "" {
return ""
}
projectDir = canonicalPath(projectDir)
absoluteRoot := canonicalPath(root)
relative, err := filepath.Rel(projectDir, absoluteRoot)
if err != nil {
return ""
}
return strconv.Quote(filepath.ToSlash(relative))
}
func canonicalPath(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return path
}
// Resolve the nearest existing ancestor too: generation now renders before
// creating directories, and macOS /var commonly resolves through /private.
candidate := abs
var tail []string
for {
resolved, err := filepath.EvalSymlinks(candidate)
if err == nil {
for i := len(tail) - 1; i >= 0; i-- {
resolved = filepath.Join(resolved, tail[i])
}
return resolved
}
if !errors.Is(err, os.ErrNotExist) {
return abs
}
parent := filepath.Dir(candidate)
if parent == candidate {
return abs
}
tail = append(tail, filepath.Base(candidate))
candidate = parent
}
}
func findFrameworkRoot() string {
directory, err := os.Getwd()
if err != nil {
return ""
}
for {
moduleFile := filepath.Join(directory, "go.mod")
content, readErr := os.ReadFile(moduleFile)
if readErr == nil {
for _, line := range strings.Split(string(content), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[0] == "module" && strings.Trim(fields[1], "\"") == frameworkModule {
return directory
}
}
return ""
}
parent := filepath.Dir(directory)
if parent == directory {
return ""
}
directory = parent
}
}