312 lines
9.5 KiB
Go
312 lines
9.5 KiB
Go
package apigen
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const manifestPath = ".goOmni/apiManifest.json"
|
|
|
|
type manifest struct {
|
|
Profile *projectProfile `json:"profile,omitempty"`
|
|
Version int `json:"version"`
|
|
Module string `json:"module"`
|
|
Entry string `json:"entry"`
|
|
Generated map[string]string `json:"generated"`
|
|
}
|
|
type change struct {
|
|
name string
|
|
before, after []byte
|
|
existed, remove bool
|
|
mode os.FileMode
|
|
}
|
|
|
|
func apply(root, module, entry string, generated, seeds map[string][]byte, check, dryRun bool, profile projectProfile) (Output, error) {
|
|
var result Output
|
|
if info, err := os.Lstat(root); err == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return result, errors.New("api: output root must be a real directory")
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return result, err
|
|
}
|
|
root = canonical(root)
|
|
names := map[string]bool{}
|
|
caseNames := map[string]string{}
|
|
for _, files := range []map[string][]byte{generated, seeds} {
|
|
for name := range files {
|
|
if names[name] {
|
|
return result, fmt.Errorf("duplicate output %s", name)
|
|
}
|
|
names[name] = true
|
|
folded := strings.ToLower(name)
|
|
if old, ok := caseNames[folded]; ok {
|
|
return result, fmt.Errorf("case-insensitive path collision: %s and %s", old, name)
|
|
}
|
|
caseNames[folded] = name
|
|
if err := safePath(root, name); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
}
|
|
if err := safePath(root, manifestPath); err != nil {
|
|
return result, err
|
|
}
|
|
if !check && !dryRun {
|
|
if err := os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil {
|
|
return result, err
|
|
}
|
|
lock := filepath.Join(root, ".goOmni", "api.lock")
|
|
f, err := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
|
if err != nil {
|
|
return result, fmt.Errorf("api: acquire generation lock (remove stale api.lock only after checking no generator is running): %w", err)
|
|
}
|
|
_, _ = fmt.Fprintf(f, "pid=%d\n", os.Getpid())
|
|
_ = f.Close()
|
|
defer os.Remove(lock)
|
|
}
|
|
old := manifest{Generated: map[string]string{}}
|
|
oldData, err := os.ReadFile(filepath.Join(root, manifestPath))
|
|
if err == nil {
|
|
if err = json.Unmarshal(oldData, &old); err != nil {
|
|
return result, fmt.Errorf("invalid ownership manifest: %w", err)
|
|
}
|
|
if old.Profile != nil && *old.Profile != profile {
|
|
return result, errors.New("api: profile changed during generation; retry")
|
|
}
|
|
if old.Version != 1 || old.Module != module || old.Entry != entry {
|
|
return result, errors.New("api: manifest version/module/entry differs; one API entry owns this project")
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return result, err
|
|
}
|
|
if oldData == nil {
|
|
if err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
|
if errors.Is(walkErr, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() && d.Name() == ".git" {
|
|
return filepath.SkipDir
|
|
}
|
|
if !d.IsDir() && strings.HasSuffix(d.Name(), ".go") {
|
|
return fmt.Errorf("api: %s belongs to an existing Go layout; generate into a new directory", path)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
next := manifest{Profile: &profile, Version: 1, Module: module, Entry: entry, Generated: map[string]string{}}
|
|
for name, data := range generated {
|
|
next.Generated[name] = digest(data)
|
|
}
|
|
var changes []change
|
|
// Check ALL previous outputs, even stale ones, before writing any new file.
|
|
for name, hash := range old.Generated {
|
|
if !ownedPath(name) {
|
|
return result, fmt.Errorf("api: invalid owned path %q", name)
|
|
}
|
|
if err = safePath(root, name); err != nil {
|
|
return result, err
|
|
}
|
|
data, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
|
|
if errors.Is(e, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
if e != nil {
|
|
return result, e
|
|
}
|
|
if digest(data) != hash {
|
|
return result, fmt.Errorf("api: generated file %s was edited; move custom code into developer-owned files before regenerating", name)
|
|
}
|
|
if _, exists := generated[name]; !exists {
|
|
info, _ := os.Stat(filepath.Join(root, filepath.FromSlash(name)))
|
|
changes = append(changes, change{name: name, before: data, existed: true, remove: true, mode: info.Mode().Perm()})
|
|
result.Removed = append(result.Removed, name)
|
|
}
|
|
}
|
|
paths := make([]string, 0, len(names))
|
|
for name := range names {
|
|
paths = append(paths, name)
|
|
}
|
|
sort.Strings(paths)
|
|
for _, name := range paths {
|
|
target := filepath.Join(root, filepath.FromSlash(name))
|
|
current, err := os.ReadFile(target)
|
|
exists := err == nil
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return result, err
|
|
}
|
|
content, isGenerated := generated[name]
|
|
if !isGenerated {
|
|
content = seeds[name]
|
|
}
|
|
if exists && !isGenerated {
|
|
result.Preserved = append(result.Preserved, name)
|
|
continue
|
|
}
|
|
if exists && isGenerated {
|
|
if _, owned := old.Generated[name]; !owned {
|
|
return result, fmt.Errorf("api: refusing to adopt existing unowned file %s", name)
|
|
}
|
|
}
|
|
if isGenerated {
|
|
result.Generated = append(result.Generated, name)
|
|
} else {
|
|
result.Created = append(result.Created, name)
|
|
}
|
|
if exists && bytes.Equal(current, content) {
|
|
continue
|
|
}
|
|
mode := os.FileMode(0644)
|
|
if exists {
|
|
info, e := os.Stat(target)
|
|
if e != nil {
|
|
return result, e
|
|
}
|
|
mode = info.Mode().Perm()
|
|
}
|
|
changes = append(changes, change{name: name, before: current, after: content, existed: exists, mode: mode})
|
|
}
|
|
data, err := json.MarshalIndent(next, "", " ")
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
data = append(data, '\n')
|
|
if !bytes.Equal(oldData, data) {
|
|
changes = append(changes, change{name: manifestPath, before: oldData, after: data, existed: oldData != nil, mode: 0644})
|
|
}
|
|
sort.Strings(result.Removed)
|
|
for _, c := range changes {
|
|
action := "create"
|
|
if c.remove {
|
|
action = "remove"
|
|
} else if c.existed {
|
|
action = "update"
|
|
}
|
|
result.Changes = append(result.Changes, FileChange{Path: c.name, Action: action})
|
|
}
|
|
sort.Slice(result.Changes, func(i, j int) bool { return result.Changes[i].Path < result.Changes[j].Path })
|
|
if dryRun {
|
|
return result, nil
|
|
}
|
|
if check {
|
|
if len(changes) > 0 {
|
|
var stale []string
|
|
for _, c := range changes {
|
|
stale = append(stale, c.name)
|
|
}
|
|
sort.Strings(stale)
|
|
return result, fmt.Errorf("api: generated project is stale: %s", strings.Join(stale, ", "))
|
|
}
|
|
return result, nil
|
|
}
|
|
// A rollback restores bytes/modes if an ordinary filesystem write fails.
|
|
// File replacement is atomic; the whole directory is not crash-atomic.
|
|
var applied []change
|
|
for _, c := range changes {
|
|
if err = safePath(root, c.name); err == nil {
|
|
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.name)))
|
|
if c.existed {
|
|
if e != nil || !bytes.Equal(current, c.before) {
|
|
err = fmt.Errorf("file changed during generation: %s", c.name)
|
|
}
|
|
} else if !errors.Is(e, os.ErrNotExist) {
|
|
err = fmt.Errorf("file appeared during generation: %s", c.name)
|
|
}
|
|
}
|
|
if err == nil {
|
|
if c.remove {
|
|
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.name)))
|
|
} else {
|
|
err = atomicWrite(root, c.name, c.after, c.mode)
|
|
}
|
|
}
|
|
if err != nil {
|
|
for i := len(applied) - 1; i >= 0; i-- {
|
|
previous := applied[i]
|
|
var rollback error
|
|
if previous.existed {
|
|
rollback = atomicWrite(root, previous.name, previous.before, previous.mode)
|
|
} else {
|
|
rollback = os.Remove(filepath.Join(root, filepath.FromSlash(previous.name)))
|
|
}
|
|
err = errors.Join(err, rollback)
|
|
}
|
|
return result, err
|
|
}
|
|
applied = append(applied, c)
|
|
}
|
|
return result, nil
|
|
}
|
|
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
|
func ownedPath(name string) bool {
|
|
if name == "internal/handler/responseGen.go" || name == "internal/types/scalarsGen.go" {
|
|
return true
|
|
}
|
|
if name == "internal/apierrors/errorsGen.go" || name == "docs/openapi.json" || name == "internal/types/typesGen.go" || name == "internal/handler/routesGen.go" || name == "internal/handler/openapiGen.go" || name == "internal/middleware/registryGen.go" {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go")
|
|
}
|
|
func safePath(root, name string) error {
|
|
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
|
|
return fmt.Errorf("api: unsafe output path %q", name)
|
|
}
|
|
path := root
|
|
parts := strings.Split(name, "/")
|
|
for i, part := range parts {
|
|
path = filepath.Join(path, part)
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("api: refusing symlink %s", path)
|
|
}
|
|
if i < len(parts)-1 && !info.IsDir() {
|
|
return fmt.Errorf("api: expected directory %s", path)
|
|
}
|
|
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
|
return fmt.Errorf("api: expected regular file %s", path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func atomicWrite(root, name string, data []byte, mode os.FileMode) error {
|
|
target := filepath.Join(root, filepath.FromSlash(name))
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.CreateTemp(filepath.Dir(target), ".apiGen-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(f.Name())
|
|
if err = f.Chmod(mode); err == nil {
|
|
_, err = f.Write(data)
|
|
}
|
|
if err == nil {
|
|
err = f.Sync()
|
|
}
|
|
err = errors.Join(err, f.Close())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(f.Name(), target)
|
|
}
|