219 lines
6.6 KiB
Go
219 lines
6.6 KiB
Go
package apigen
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
|
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
|
)
|
|
|
|
type WorkspaceService struct {
|
|
Dir string `json:"dir"`
|
|
Kind string `json:"kind,omitempty"`
|
|
API string `json:"api,omitempty"`
|
|
Module string `json:"module,omitempty"`
|
|
}
|
|
type WorkspaceConfig struct {
|
|
Services []WorkspaceService `json:"services"`
|
|
ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"`
|
|
}
|
|
type workspaceTarget struct {
|
|
dir, kind, module, entry string
|
|
err error
|
|
}
|
|
|
|
// Workspace dispatches to each independent project's generator. Directory/schema
|
|
// validation precedes any providers; all targets are preflighted before writes.
|
|
// Transactions remain per-project, not crash-atomic across the workspace.
|
|
func Workspace(path, command string, w io.Writer) error {
|
|
if command != "generate" && command != "check" && command != "format" {
|
|
return errors.New("workspace command must be generate, check or format")
|
|
}
|
|
if w == nil {
|
|
w = io.Discard
|
|
}
|
|
real, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stat, err := os.Lstat(real)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !stat.Mode().IsRegular() || stat.Size() > 65536 {
|
|
return errors.New("workspace configuration must be a regular file of at most 64 KiB")
|
|
}
|
|
raw, err := os.ReadFile(real)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var cfg WorkspaceConfig
|
|
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
|
return err
|
|
}
|
|
if len(cfg.Services) == 0 || len(cfg.Services) > 64 {
|
|
return errors.New("workspace must list 1..64 services")
|
|
}
|
|
root := canonical(filepath.Dir(real))
|
|
seen := map[string]bool{}
|
|
// Validate the entire workspace boundary before resolving or running any target.
|
|
for _, service := range cfg.Services {
|
|
if service.Dir == "" || service.Dir == "." {
|
|
return errors.New("workspace requires distinct relative service directories")
|
|
}
|
|
if err = safePath(root, service.Dir+"/.goOmniWorkspaceCheck"); err != nil {
|
|
return err
|
|
}
|
|
key := strings.ToLower(filepath.Join(root, filepath.FromSlash(service.Dir)))
|
|
for prior := range seen {
|
|
if prior == key || strings.HasPrefix(key, prior+string(filepath.Separator)) || strings.HasPrefix(prior, key+string(filepath.Separator)) {
|
|
return errors.New("workspace service directories overlap")
|
|
}
|
|
}
|
|
seen[key] = true
|
|
}
|
|
targets := make([]workspaceTarget, 0, len(cfg.Services))
|
|
for _, service := range cfg.Services {
|
|
t := workspaceTarget{dir: filepath.Join(root, filepath.FromSlash(service.Dir)), module: service.Module}
|
|
t.kind, t.err = workspaceKind(t.dir, service.Kind, service.API)
|
|
if t.err == nil && t.kind == "api" {
|
|
if service.API == "" {
|
|
t.entry, t.err = ProjectEntry(t.dir)
|
|
} else if t.err = safePath(t.dir, service.API); t.err == nil {
|
|
t.entry = filepath.Join(t.dir, filepath.FromSlash(service.API))
|
|
}
|
|
}
|
|
targets = append(targets, t)
|
|
}
|
|
var catalogs map[string][]byte
|
|
if command != "format" {
|
|
catalogs, err = prepareWorkspaceCatalogs(root, cfg.ErrorCatalogs, command == "check")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var failures error
|
|
report := func(t workspaceTarget, e error) {
|
|
if e != nil {
|
|
failures = errors.Join(failures, fmt.Errorf("%s: %w", t.dir, e))
|
|
fmt.Fprintf(w, "FAIL %s: %v\n", t.dir, e)
|
|
} else if command == "format" && t.kind == "project" {
|
|
fmt.Fprintf(w, "SKIP %s (project has no API contract to format)\n", t.dir)
|
|
} else {
|
|
fmt.Fprintf(w, "OK %s\n", t.dir)
|
|
}
|
|
}
|
|
if command == "check" {
|
|
// Each generator checks its own manifest and managed outputs. An invalid
|
|
// project must not suppress diagnostics for another independent project.
|
|
for _, t := range targets {
|
|
report(t, runWorkspaceTarget(t, command, false, catalogs))
|
|
}
|
|
return failures
|
|
}
|
|
for i := range targets {
|
|
t := &targets[i]
|
|
t.err = runWorkspaceTarget(*t, command, true, catalogs)
|
|
}
|
|
for _, t := range targets {
|
|
if t.err != nil {
|
|
report(t, t.err)
|
|
}
|
|
}
|
|
if failures != nil {
|
|
for _, t := range targets {
|
|
if t.err == nil {
|
|
fmt.Fprintf(w, "SKIP %s (workspace preflight failed; no changes applied)\n", t.dir)
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
if command == "generate" {
|
|
for path, data := range catalogs {
|
|
if err = writeWorkspaceCatalog(path, data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for _, t := range targets {
|
|
report(t, runWorkspaceTarget(t, command, false, catalogs))
|
|
}
|
|
return failures
|
|
}
|
|
|
|
// An explicit API entry remains supported before the first API generation.
|
|
// Without manifests, omitted kind preserves the previous API interpretation.
|
|
// Manifests of both kinds or a conflicting declaration never silently choose one.
|
|
func workspaceKind(dir, kind, entry string) (string, error) {
|
|
if kind != "" && kind != "api" && kind != "project" {
|
|
return "", fmt.Errorf("unknown workspace kind %q; expected api or project", kind)
|
|
}
|
|
detected := ""
|
|
for _, candidate := range []struct{ kind, path string }{{"api", manifestPath}, {"project", ".goOmni/projectManifest.json"}} {
|
|
if err := safePath(dir, candidate.path); err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(dir, candidate.path)); err == nil {
|
|
if detected != "" {
|
|
return "", errors.New("ambiguous project: both API and project manifests exist")
|
|
}
|
|
detected = candidate.kind
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return "", err
|
|
}
|
|
}
|
|
if kind != "" && detected != "" && kind != detected {
|
|
return "", fmt.Errorf("workspace kind %q conflicts with %s manifest", kind, detected)
|
|
}
|
|
if kind == "" {
|
|
kind = detected
|
|
}
|
|
if kind == "" {
|
|
kind = "api"
|
|
}
|
|
if kind == "project" && entry != "" {
|
|
return "", errors.New("workspace project kind does not accept an api entry")
|
|
}
|
|
return kind, nil
|
|
}
|
|
|
|
func runWorkspaceTarget(t workspaceTarget, command string, preview bool, catalogs map[string][]byte) error {
|
|
if t.err != nil {
|
|
return t.err
|
|
}
|
|
if t.kind == "project" {
|
|
if command == "format" {
|
|
return nil
|
|
}
|
|
_, err := projectgen.Generate(projectgen.Options{Dir: t.dir, Module: t.module, DryRun: preview, Check: command == "check"})
|
|
return err
|
|
}
|
|
o := Options{API: t.entry, Dir: t.dir, Module: t.module, DryRun: preview, documentCatalogs: catalogs}
|
|
switch command {
|
|
case "format":
|
|
if preview {
|
|
spec, err := Parse(t.entry)
|
|
if err == nil {
|
|
_, err = Compile(spec)
|
|
}
|
|
return err
|
|
}
|
|
_, err := FormatProject(t.entry, false)
|
|
return err
|
|
case "generate":
|
|
_, err := Generate(o)
|
|
return err
|
|
case "check":
|
|
_, formatErr := FormatProject(t.entry, true)
|
|
o.Check = true
|
|
_, generateErr := Generate(o)
|
|
return errors.Join(formatErr, generateErr)
|
|
}
|
|
return errors.New("unsupported workspace command")
|
|
}
|