gitlink-cli/cmd/api/batch.go

498 lines
15 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"regexp"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
type batchPlan struct {
Vars map[string]string `json:"vars"`
Requests []batchRequest `json:"requests"`
}
type batchRequest struct {
Name string `json:"name"`
Method string `json:"method"`
Path string `json:"path"`
Query map[string]interface{} `json:"query"`
Body interface{} `json:"body"`
Save map[string]string `json:"save"`
}
type renderedBatchRequest struct {
Index int `json:"index" yaml:"index"`
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"`
Query url.Values `json:"query,omitempty" yaml:"query,omitempty"`
Body interface{} `json:"body,omitempty" yaml:"body,omitempty"`
Save map[string]string `json:"save,omitempty" yaml:"save,omitempty"`
}
type batchResult struct {
Index int `json:"index" yaml:"index"`
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"`
OK bool `json:"ok" yaml:"ok"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
Data interface{} `json:"data,omitempty" yaml:"data,omitempty"`
Saved map[string]string `json:"saved,omitempty" yaml:"saved,omitempty"`
}
type batchSummary struct {
DryRun bool `json:"dry_run" yaml:"dry_run"`
ContinueOnError bool `json:"continue_on_error" yaml:"continue_on_error"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Variables map[string]string `json:"variables,omitempty" yaml:"variables,omitempty"`
Requests []renderedBatchRequest `json:"requests,omitempty" yaml:"requests,omitempty"`
Results []batchResult `json:"results,omitempty" yaml:"results,omitempty"`
}
var templatePattern = regexp.MustCompile(`\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}`)
func runAPIBatch(c *cobra.Command, batchFile string) error {
if hasSingleRequestInput(c) {
return fmt.Errorf("use batch flags separately from --body, --body-file, --body-stdin, --query, or --header")
}
dryRun, _ := c.Flags().GetBool("dry-run")
continueOnError, _ := c.Flags().GetBool("continue-on-error")
overrides, err := parseBatchVars(c)
if err != nil {
return err
}
plan, err := readBatchPlan(batchFile)
if err != nil {
return err
}
vars := mergeBatchVars(plan.Vars, overrides)
requests, err := renderBatchRequestsForDryRun(plan.Requests, vars)
if err != nil {
return err
}
if dryRun {
return output.Print(output.SuccessEnvelope(batchSummary{
DryRun: true,
ContinueOnError: continueOnError,
Total: len(requests),
Variables: sortedVars(vars),
Requests: requests,
}, nil), resolveFormat())
}
cli, err := client.New()
if err != nil {
return err
}
cli.Debug = cmdutil.Debug
summary := batchSummary{
DryRun: false,
ContinueOnError: continueOnError,
Total: len(plan.Requests),
Variables: sortedVars(vars),
Results: make([]batchResult, 0, len(plan.Requests)),
}
for i, rawReq := range plan.Requests {
req, err := renderBatchRequest(i, rawReq, vars, false)
if err != nil {
return err
}
result := batchResult{Index: req.Index, Name: req.Name, Method: req.Method, Path: req.Path}
env, callErr := cli.Do(req.Method, req.Path, req.Body, req.Query)
if callErr != nil {
summary.Failed++
result.OK = false
result.Error = apiBatchErrorMessage(callErr)
summary.Results = append(summary.Results, result)
if !continueOnError {
_ = output.Print(output.SuccessEnvelope(summary, nil), resolveFormat())
return callErr
}
continue
}
summary.Succeeded++
result.OK = true
if env != nil {
result.Data = env.Data
if len(req.Save) > 0 {
saved, err := extractBatchSavedVars(env.Data, req.Save)
if err != nil {
summary.Succeeded--
summary.Failed++
result.OK = false
result.Error = err.Error()
summary.Results = append(summary.Results, result)
if !continueOnError {
_ = output.Print(output.SuccessEnvelope(summary, nil), resolveFormat())
return err
}
continue
}
for key, value := range saved {
vars[key] = value
}
result.Saved = saved
}
}
summary.Results = append(summary.Results, result)
}
return output.Print(output.SuccessEnvelope(summary, nil), resolveFormat())
}
func hasSingleRequestInput(c *cobra.Command) bool {
body, _ := c.Flags().GetString("body")
bodyFile, _ := c.Flags().GetString("body-file")
bodyStdin, _ := c.Flags().GetBool("body-stdin")
query, _ := c.Flags().GetString("query")
headers, _ := c.Flags().GetStringSlice("header")
return body != "" || bodyFile != "" || bodyStdin || query != "" || len(headers) > 0
}
func parseBatchVars(c *cobra.Command) (map[string]string, error) {
raw, _ := c.Flags().GetStringArray("var")
vars := make(map[string]string, len(raw))
for _, item := range raw {
key, value, ok := strings.Cut(item, "=")
key = strings.TrimSpace(key)
if !ok || key == "" {
return nil, fmt.Errorf("invalid --var %q, want key=value", item)
}
vars[key] = value
}
return vars, nil
}
func readBatchPlan(path string) (*batchPlan, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read batch file: %w", err)
}
var plan batchPlan
if err := json.Unmarshal(data, &plan); err != nil {
return nil, fmt.Errorf("invalid batch file JSON: %w", err)
}
if len(plan.Requests) == 0 {
return nil, fmt.Errorf("batch file must contain at least one request")
}
return &plan, nil
}
func mergeBatchVars(planVars, overrides map[string]string) map[string]string {
vars := make(map[string]string, len(planVars)+len(overrides))
for key, value := range planVars {
vars[key] = value
}
for key, value := range overrides {
vars[key] = value
}
return vars
}
func renderBatchRequests(requests []batchRequest, vars map[string]string) ([]renderedBatchRequest, error) {
rendered := make([]renderedBatchRequest, 0, len(requests))
for i, req := range requests {
renderedReq, err := renderBatchRequest(i, req, vars, false)
if err != nil {
return nil, err
}
rendered = append(rendered, renderedReq)
}
return rendered, nil
}
func renderBatchRequestsForDryRun(requests []batchRequest, vars map[string]string) ([]renderedBatchRequest, error) {
rendered := make([]renderedBatchRequest, 0, len(requests))
for i, req := range requests {
renderedReq, err := renderBatchRequest(i, req, vars, true)
if err != nil {
return nil, err
}
rendered = append(rendered, renderedReq)
}
return rendered, nil
}
func renderBatchRequest(i int, req batchRequest, vars map[string]string, allowMissing bool) (renderedBatchRequest, error) {
method := strings.ToUpper(strings.TrimSpace(req.Method))
if method == "" {
return renderedBatchRequest{}, fmt.Errorf("request %d method is required", i+1)
}
path, err := renderTemplateWithOptions(req.Path, vars, allowMissing)
if err != nil {
return renderedBatchRequest{}, fmt.Errorf("request %d path: %w", i+1, err)
}
path = strings.TrimSpace(path)
if path == "" {
return renderedBatchRequest{}, fmt.Errorf("request %d path is required", i+1)
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
query, err := renderBatchQueryWithOptions(req.Query, vars, allowMissing)
if err != nil {
return renderedBatchRequest{}, fmt.Errorf("request %d query: %w", i+1, err)
}
body, err := renderBatchValueWithOptions(req.Body, vars, allowMissing)
if err != nil {
return renderedBatchRequest{}, fmt.Errorf("request %d body: %w", i+1, err)
}
name, err := renderTemplateWithOptions(req.Name, vars, allowMissing)
if err != nil {
return renderedBatchRequest{}, fmt.Errorf("request %d name: %w", i+1, err)
}
save, err := renderBatchSave(req.Save, vars, allowMissing)
if err != nil {
return renderedBatchRequest{}, fmt.Errorf("request %d save: %w", i+1, err)
}
return renderedBatchRequest{Index: i + 1, Name: name, Method: method, Path: path, Query: query, Body: body, Save: save}, nil
}
func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.Values, error) {
return renderBatchQueryWithOptions(raw, vars, false)
}
func renderBatchQueryWithOptions(raw map[string]interface{}, vars map[string]string, allowMissing bool) (url.Values, error) {
if len(raw) == 0 {
return nil, nil
}
query := url.Values{}
keys := make([]string, 0, len(raw))
for key := range raw {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
renderedKey, err := renderTemplateWithOptions(key, vars, allowMissing)
if err != nil {
return nil, err
}
values, err := renderQueryValuesWithOptions(raw[key], vars, allowMissing)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
for _, value := range values {
query.Add(renderedKey, value)
}
}
return query, nil
}
func renderQueryValues(raw interface{}, vars map[string]string) ([]string, error) {
return renderQueryValuesWithOptions(raw, vars, false)
}
func renderQueryValuesWithOptions(raw interface{}, vars map[string]string, allowMissing bool) ([]string, error) {
switch value := raw.(type) {
case nil:
return []string{""}, nil
case string:
rendered, err := renderTemplateWithOptions(value, vars, allowMissing)
return []string{rendered}, err
case []interface{}:
values := make([]string, 0, len(value))
for _, item := range value {
itemValues, err := renderQueryValuesWithOptions(item, vars, allowMissing)
if err != nil {
return nil, err
}
values = append(values, itemValues...)
}
return values, nil
default:
return []string{fmt.Sprint(value)}, nil
}
}
func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, error) {
return renderBatchValueWithOptions(raw, vars, false)
}
func renderBatchValueWithOptions(raw interface{}, vars map[string]string, allowMissing bool) (interface{}, error) {
switch value := raw.(type) {
case nil:
return nil, nil
case string:
return renderTemplateWithOptions(value, vars, allowMissing)
case []interface{}:
items := make([]interface{}, 0, len(value))
for _, item := range value {
rendered, err := renderBatchValueWithOptions(item, vars, allowMissing)
if err != nil {
return nil, err
}
items = append(items, rendered)
}
return items, nil
case map[string]interface{}:
obj := make(map[string]interface{}, len(value))
for key, item := range value {
renderedKey, err := renderTemplateWithOptions(key, vars, allowMissing)
if err != nil {
return nil, err
}
rendered, err := renderBatchValueWithOptions(item, vars, allowMissing)
if err != nil {
return nil, err
}
obj[renderedKey] = rendered
}
return obj, nil
default:
return raw, nil
}
}
func renderBatchSave(raw map[string]string, vars map[string]string, allowMissing bool) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
save := make(map[string]string, len(raw))
keys := make([]string, 0, len(raw))
for key := range raw {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
renderedKey, err := renderTemplateWithOptions(key, vars, allowMissing)
if err != nil {
return nil, err
}
renderedKey = strings.TrimSpace(renderedKey)
if renderedKey == "" {
return nil, fmt.Errorf("save variable name cannot be empty")
}
renderedPath, err := renderTemplateWithOptions(raw[key], vars, allowMissing)
if err != nil {
return nil, err
}
renderedPath = strings.TrimSpace(renderedPath)
if renderedPath == "" {
return nil, fmt.Errorf("save path for %q cannot be empty", renderedKey)
}
save[renderedKey] = renderedPath
}
return save, nil
}
func renderTemplate(value string, vars map[string]string) (string, error) {
return renderTemplateWithOptions(value, vars, false)
}
func renderTemplateWithOptions(value string, vars map[string]string, allowMissing bool) (string, error) {
var missing []string
rendered := templatePattern.ReplaceAllStringFunc(value, func(match string) string {
parts := templatePattern.FindStringSubmatch(match)
if len(parts) != 2 {
return match
}
replacement, ok := vars[parts[1]]
if !ok {
if allowMissing {
return match
}
missing = append(missing, parts[1])
return match
}
return replacement
})
if len(missing) > 0 {
sort.Strings(missing)
return "", fmt.Errorf("missing template variable(s): %s", strings.Join(missing, ", "))
}
return rendered, nil
}
func extractBatchSavedVars(data interface{}, save map[string]string) (map[string]string, error) {
if len(save) == 0 {
return nil, nil
}
saved := make(map[string]string, len(save))
keys := make([]string, 0, len(save))
for key := range save {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
value, err := extractBatchPath(data, save[key])
if err != nil {
return nil, fmt.Errorf("save %s from %s: %w", key, save[key], err)
}
saved[key] = fmt.Sprint(value)
}
return saved, nil
}
func extractBatchPath(data interface{}, path string) (interface{}, error) {
current := data
parts := strings.Split(path, ".")
if len(parts) > 0 && parts[0] == "data" {
parts = parts[1:]
}
for _, part := range parts {
if part == "" {
return nil, fmt.Errorf("empty path segment")
}
switch value := current.(type) {
case map[string]interface{}:
next, ok := value[part]
if !ok {
return nil, fmt.Errorf("field %q not found", part)
}
current = next
case []interface{}:
index, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("segment %q is not an array index", part)
}
if index < 0 || index >= len(value) {
return nil, fmt.Errorf("array index %d out of range", index)
}
current = value[index]
default:
return nil, fmt.Errorf("cannot descend into %T", current)
}
}
if current == nil {
return nil, fmt.Errorf("value is null")
}
return current, nil
}
func sortedVars(vars map[string]string) map[string]string {
if len(vars) == 0 {
return nil
}
copyVars := make(map[string]string, len(vars))
for key, value := range vars {
copyVars[key] = value
}
return copyVars
}
func apiBatchErrorMessage(err error) string {
var apiErr *client.APIError
if errors.As(err, &apiErr) {
return apiErr.Message
}
return err.Error()
}