Compare commits
1 Commits
master
...
feat/api-b
| Author | SHA1 | Date |
|---|---|---|
|
|
2d0972fea0 |
|
|
@ -695,6 +695,10 @@ Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-s
|
||||||
|
|
||||||
# With query parameters
|
# With query parameters
|
||||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||||
|
|
||||||
|
# Multi-step batch plan, including saved variables from earlier responses
|
||||||
|
gitlink-cli api --batch-file plan.json --dry-run
|
||||||
|
gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=forgeplus
|
||||||
```
|
```
|
||||||
|
|
||||||
## Global Parameters
|
## Global Parameters
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,10 @@ Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-s
|
||||||
|
|
||||||
# 带查询参数
|
# 带查询参数
|
||||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||||
|
|
||||||
|
# 多步骤批处理,可保存前序响应字段作为后续变量
|
||||||
|
gitlink-cli api --batch-file plan.json --dry-run
|
||||||
|
gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=forgeplus
|
||||||
```
|
```
|
||||||
|
|
||||||
## 全局参数
|
## 全局参数
|
||||||
|
|
|
||||||
|
|
@ -402,3 +402,87 @@ func writeBatchPlan(t *testing.T, payload interface{}) string {
|
||||||
}
|
}
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunAPIBatchSavesVarsForLaterRequests(t *testing.T) {
|
||||||
|
var gotBody map[string]interface{}
|
||||||
|
var seen []string
|
||||||
|
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seen = append(seen, r.Method+" "+r.URL.Path)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/owner/repo/issues.json":
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"issues": []interface{}{
|
||||||
|
map[string]interface{}{"id": float64(42), "subject": "Saved subject"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "/v1/owner/repo/issues/42/journals.json":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||||
|
t.Fatalf("decode body: %v", err)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"id": 99})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cmdutil.Format = "json"
|
||||||
|
|
||||||
|
plan := writeBatchPlan(t, map[string]interface{}{
|
||||||
|
"vars": map[string]string{"owner": "owner", "repo": "repo"},
|
||||||
|
"requests": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "find-issue",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/v1/{{owner}}/{{repo}}/issues",
|
||||||
|
"save": map[string]string{
|
||||||
|
"issue": "issues.0.id",
|
||||||
|
"subject": "data.issues.0.subject",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment-{{issue}}",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals",
|
||||||
|
"body": map[string]interface{}{"notes": "Follow up: {{subject}}"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewAPICmd()
|
||||||
|
cmd.SetArgs([]string{"--batch-file", plan})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("batch execute error: %v", err)
|
||||||
|
}
|
||||||
|
if len(seen) != 2 {
|
||||||
|
t.Fatalf("seen = %v, want 2 requests", seen)
|
||||||
|
}
|
||||||
|
if gotBody["notes"] != "Follow up: Saved subject" {
|
||||||
|
t.Fatalf("notes = %#v", gotBody["notes"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAPIBatchDryRunAllowsSavedVars(t *testing.T) {
|
||||||
|
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("dry-run should not reach server")
|
||||||
|
})
|
||||||
|
cmdutil.Format = "json"
|
||||||
|
|
||||||
|
plan := writeBatchPlan(t, map[string]interface{}{
|
||||||
|
"requests": []map[string]interface{}{
|
||||||
|
{"method": "GET", "path": "/issues", "save": map[string]string{"issue": "issues.0.id"}},
|
||||||
|
{"method": "GET", "path": "/issues/{{issue}}"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewAPICmd()
|
||||||
|
cmd.SetArgs([]string{"--batch-file", plan, "--dry-run"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("dry-run with future save var failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractBatchSavedVarsErrorsOnMissingPath(t *testing.T) {
|
||||||
|
_, err := extractBatchSavedVars(map[string]interface{}{"issues": []interface{}{}}, map[string]string{"issue": "issues.0.id"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing path error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
272
cmd/api/batch.go
272
cmd/api/batch.go
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
@ -28,25 +29,28 @@ type batchRequest struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Query map[string]interface{} `json:"query"`
|
Query map[string]interface{} `json:"query"`
|
||||||
Body interface{} `json:"body"`
|
Body interface{} `json:"body"`
|
||||||
|
Save map[string]string `json:"save"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type renderedBatchRequest struct {
|
type renderedBatchRequest struct {
|
||||||
Index int `json:"index" yaml:"index"`
|
Index int `json:"index" yaml:"index"`
|
||||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||||
Method string `json:"method" yaml:"method"`
|
Method string `json:"method" yaml:"method"`
|
||||||
Path string `json:"path" yaml:"path"`
|
Path string `json:"path" yaml:"path"`
|
||||||
Query url.Values `json:"query,omitempty" yaml:"query,omitempty"`
|
Query url.Values `json:"query,omitempty" yaml:"query,omitempty"`
|
||||||
Body interface{} `json:"body,omitempty" yaml:"body,omitempty"`
|
Body interface{} `json:"body,omitempty" yaml:"body,omitempty"`
|
||||||
|
Save map[string]string `json:"save,omitempty" yaml:"save,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type batchResult struct {
|
type batchResult struct {
|
||||||
Index int `json:"index" yaml:"index"`
|
Index int `json:"index" yaml:"index"`
|
||||||
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
Name string `json:"name,omitempty" yaml:"name,omitempty"`
|
||||||
Method string `json:"method" yaml:"method"`
|
Method string `json:"method" yaml:"method"`
|
||||||
Path string `json:"path" yaml:"path"`
|
Path string `json:"path" yaml:"path"`
|
||||||
OK bool `json:"ok" yaml:"ok"`
|
OK bool `json:"ok" yaml:"ok"`
|
||||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||||
Data interface{} `json:"data,omitempty" yaml:"data,omitempty"`
|
Data interface{} `json:"data,omitempty" yaml:"data,omitempty"`
|
||||||
|
Saved map[string]string `json:"saved,omitempty" yaml:"saved,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type batchSummary struct {
|
type batchSummary struct {
|
||||||
|
|
@ -79,7 +83,7 @@ func runAPIBatch(c *cobra.Command, batchFile string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
vars := mergeBatchVars(plan.Vars, overrides)
|
vars := mergeBatchVars(plan.Vars, overrides)
|
||||||
requests, err := renderBatchRequests(plan.Requests, vars)
|
requests, err := renderBatchRequestsForDryRun(plan.Requests, vars)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -102,17 +106,16 @@ func runAPIBatch(c *cobra.Command, batchFile string) error {
|
||||||
summary := batchSummary{
|
summary := batchSummary{
|
||||||
DryRun: false,
|
DryRun: false,
|
||||||
ContinueOnError: continueOnError,
|
ContinueOnError: continueOnError,
|
||||||
Total: len(requests),
|
Total: len(plan.Requests),
|
||||||
Variables: sortedVars(vars),
|
Variables: sortedVars(vars),
|
||||||
Results: make([]batchResult, 0, len(requests)),
|
Results: make([]batchResult, 0, len(plan.Requests)),
|
||||||
}
|
}
|
||||||
for _, req := range requests {
|
for i, rawReq := range plan.Requests {
|
||||||
result := batchResult{
|
req, err := renderBatchRequest(i, rawReq, vars, false)
|
||||||
Index: req.Index,
|
if err != nil {
|
||||||
Name: req.Name,
|
return err
|
||||||
Method: req.Method,
|
|
||||||
Path: req.Path,
|
|
||||||
}
|
}
|
||||||
|
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)
|
env, callErr := cli.Do(req.Method, req.Path, req.Body, req.Query)
|
||||||
if callErr != nil {
|
if callErr != nil {
|
||||||
summary.Failed++
|
summary.Failed++
|
||||||
|
|
@ -129,6 +132,25 @@ func runAPIBatch(c *cobra.Command, batchFile string) error {
|
||||||
result.OK = true
|
result.OK = true
|
||||||
if env != nil {
|
if env != nil {
|
||||||
result.Data = env.Data
|
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)
|
summary.Results = append(summary.Results, result)
|
||||||
}
|
}
|
||||||
|
|
@ -188,46 +210,67 @@ func mergeBatchVars(planVars, overrides map[string]string) map[string]string {
|
||||||
func renderBatchRequests(requests []batchRequest, vars map[string]string) ([]renderedBatchRequest, error) {
|
func renderBatchRequests(requests []batchRequest, vars map[string]string) ([]renderedBatchRequest, error) {
|
||||||
rendered := make([]renderedBatchRequest, 0, len(requests))
|
rendered := make([]renderedBatchRequest, 0, len(requests))
|
||||||
for i, req := range requests {
|
for i, req := range requests {
|
||||||
method := strings.ToUpper(strings.TrimSpace(req.Method))
|
renderedReq, err := renderBatchRequest(i, req, vars, false)
|
||||||
if method == "" {
|
|
||||||
return nil, fmt.Errorf("request %d method is required", i+1)
|
|
||||||
}
|
|
||||||
path, err := renderTemplate(req.Path, vars)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request %d path: %w", i+1, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
path = strings.TrimSpace(path)
|
rendered = append(rendered, renderedReq)
|
||||||
if path == "" {
|
|
||||||
return nil, fmt.Errorf("request %d path is required", i+1)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(path, "/") {
|
|
||||||
path = "/" + path
|
|
||||||
}
|
|
||||||
query, err := renderBatchQuery(req.Query, vars)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("request %d query: %w", i+1, err)
|
|
||||||
}
|
|
||||||
body, err := renderBatchValue(req.Body, vars)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("request %d body: %w", i+1, err)
|
|
||||||
}
|
|
||||||
name, err := renderTemplate(req.Name, vars)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("request %d name: %w", i+1, err)
|
|
||||||
}
|
|
||||||
rendered = append(rendered, renderedBatchRequest{
|
|
||||||
Index: i + 1,
|
|
||||||
Name: name,
|
|
||||||
Method: method,
|
|
||||||
Path: path,
|
|
||||||
Query: query,
|
|
||||||
Body: body,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return rendered, nil
|
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) {
|
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 {
|
if len(raw) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -238,11 +281,11 @@ func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.V
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
renderedKey, err := renderTemplate(key, vars)
|
renderedKey, err := renderTemplateWithOptions(key, vars, allowMissing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
values, err := renderQueryValues(raw[key], vars)
|
values, err := renderQueryValuesWithOptions(raw[key], vars, allowMissing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", key, err)
|
return nil, fmt.Errorf("%s: %w", key, err)
|
||||||
}
|
}
|
||||||
|
|
@ -254,16 +297,20 @@ func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.V
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderQueryValues(raw interface{}, vars map[string]string) ([]string, error) {
|
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) {
|
switch value := raw.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
return []string{""}, nil
|
return []string{""}, nil
|
||||||
case string:
|
case string:
|
||||||
rendered, err := renderTemplate(value, vars)
|
rendered, err := renderTemplateWithOptions(value, vars, allowMissing)
|
||||||
return []string{rendered}, err
|
return []string{rendered}, err
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
values := make([]string, 0, len(value))
|
values := make([]string, 0, len(value))
|
||||||
for _, item := range value {
|
for _, item := range value {
|
||||||
itemValues, err := renderQueryValues(item, vars)
|
itemValues, err := renderQueryValuesWithOptions(item, vars, allowMissing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -276,15 +323,19 @@ func renderQueryValues(raw interface{}, vars map[string]string) ([]string, error
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, error) {
|
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) {
|
switch value := raw.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
return nil, nil
|
return nil, nil
|
||||||
case string:
|
case string:
|
||||||
return renderTemplate(value, vars)
|
return renderTemplateWithOptions(value, vars, allowMissing)
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
items := make([]interface{}, 0, len(value))
|
items := make([]interface{}, 0, len(value))
|
||||||
for _, item := range value {
|
for _, item := range value {
|
||||||
rendered, err := renderBatchValue(item, vars)
|
rendered, err := renderBatchValueWithOptions(item, vars, allowMissing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -294,11 +345,11 @@ func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, err
|
||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
obj := make(map[string]interface{}, len(value))
|
obj := make(map[string]interface{}, len(value))
|
||||||
for key, item := range value {
|
for key, item := range value {
|
||||||
renderedKey, err := renderTemplate(key, vars)
|
renderedKey, err := renderTemplateWithOptions(key, vars, allowMissing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rendered, err := renderBatchValue(item, vars)
|
rendered, err := renderBatchValueWithOptions(item, vars, allowMissing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +361,43 @@ func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
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
|
var missing []string
|
||||||
rendered := templatePattern.ReplaceAllStringFunc(value, func(match string) string {
|
rendered := templatePattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||||
parts := templatePattern.FindStringSubmatch(match)
|
parts := templatePattern.FindStringSubmatch(match)
|
||||||
|
|
@ -319,6 +406,9 @@ func renderTemplate(value string, vars map[string]string) (string, error) {
|
||||||
}
|
}
|
||||||
replacement, ok := vars[parts[1]]
|
replacement, ok := vars[parts[1]]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if allowMissing {
|
||||||
|
return match
|
||||||
|
}
|
||||||
missing = append(missing, parts[1])
|
missing = append(missing, parts[1])
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
|
|
@ -331,6 +421,62 @@ func renderTemplate(value string, vars map[string]string) (string, error) {
|
||||||
return rendered, nil
|
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 {
|
func sortedVars(vars map[string]string) map[string]string {
|
||||||
if len(vars) == 0 {
|
if len(vars) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# API Batch Saved Variables
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Enhances `gitlink-cli api --batch-file` so a request can save fields from its response and reuse them in later requests.
|
||||||
|
|
||||||
|
A batch request can now include:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "find-issue",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/v1/{{owner}}/{{repo}}/issues",
|
||||||
|
"save": {
|
||||||
|
"issue": "issues.0.id",
|
||||||
|
"subject": "data.issues.0.subject"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Later requests can reference `{{issue}}` and `{{subject}}` in `name`, `path`, `query`, `body`, and `save` fields.
|
||||||
|
|
||||||
|
## JSON path syntax
|
||||||
|
|
||||||
|
- Dot-separated object fields: `issues.0.id`
|
||||||
|
- Array indexes: `issues.0.id`
|
||||||
|
- Optional leading `data.` is accepted for readability: `data.issues.0.id`
|
||||||
|
|
||||||
|
## Safety and behavior
|
||||||
|
|
||||||
|
- `--dry-run` now tolerates variables that will be produced by earlier `save` entries, preserving unresolved `{{var}}` placeholders in the preview.
|
||||||
|
- If a save path is missing during execution, the batch result records the error and obeys `--continue-on-error`.
|
||||||
|
- Each result includes a `saved` map when variables were extracted successfully.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --check
|
||||||
|
GOPROXY=https://goproxy.cn,direct go test ./cmd/api
|
||||||
|
go vet ./cmd/api
|
||||||
|
GOPROXY=https://goproxy.cn,direct go test ./...
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
@ -19,14 +19,18 @@
|
||||||
"query": {
|
"query": {
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"limit": 20
|
"limit": 20
|
||||||
|
},
|
||||||
|
"save": {
|
||||||
|
"issue": "issues.0.id",
|
||||||
|
"subject": "data.issues.0.subject"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "comment-issue",
|
"name": "comment-issue-{{issue}}",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals",
|
"path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals",
|
||||||
"body": {
|
"body": {
|
||||||
"notes": "批处理自动评论"
|
"notes": "批处理自动评论:{{subject}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -50,10 +54,21 @@ gitlink-cli api --batch-file plan.json --continue-on-error --format json
|
||||||
| `requests[].path` | 是 | API 路径,可省略开头的 `/` |
|
| `requests[].path` | 是 | API 路径,可省略开头的 `/` |
|
||||||
| `requests[].query` | 否 | 查询参数对象,值可为字符串、数字、布尔值或数组 |
|
| `requests[].query` | 否 | 查询参数对象,值可为字符串、数字、布尔值或数组 |
|
||||||
| `requests[].body` | 否 | JSON 请求体,字符串字段会做模板替换 |
|
| `requests[].body` | 否 | JSON 请求体,字符串字段会做模板替换 |
|
||||||
|
| `requests[].save` | 否 | 将响应字段保存为后续模板变量,格式为 `{ "变量名": "字段路径" }` |
|
||||||
|
|
||||||
|
## save 字段路径
|
||||||
|
|
||||||
|
`save` 支持简单点号路径:
|
||||||
|
|
||||||
|
- 对象字段:`issues.0.id`
|
||||||
|
- 数组索引:`issues.0.id`
|
||||||
|
- 可选 `data.` 前缀:`data.issues.0.id`
|
||||||
|
|
||||||
|
保存成功后,后续请求可以在 `name`、`path`、`query`、`body` 和 `save` 中使用 `{{变量名}}`。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 默认遇到失败会停止;需要继续执行后续步骤时传 `--continue-on-error`。
|
- 默认遇到失败会停止;需要继续执行后续步骤时传 `--continue-on-error`。
|
||||||
- 写入类操作先用 `--dry-run` 检查渲染后的路径、query 和 body。
|
- 写入类操作先用 `--dry-run` 检查渲染后的路径、query 和 body;dry-run 会保留尚需由前序 `save` 产生的 `{{变量}}` 占位符。
|
||||||
- `--var key=value` 可重复传入,并覆盖计划文件里的同名变量,适合在不同仓库或 Issue 上复用同一计划。
|
- `--var key=value` 可重复传入,并覆盖计划文件里的同名变量,适合在不同仓库或 Issue 上复用同一计划。
|
||||||
- 批处理模式不能和单次请求的 `--body`、`--body-file`、`--body-stdin`、`--query`、`--header` 混用。
|
- 批处理模式不能和单次请求的 `--body`、`--body-file`、`--body-stdin`、`--query`、`--header` 混用。
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue