forked from Gitlink/gitlink-cli
498 lines
15 KiB
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()
|
|
}
|