Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 65 additions & 9 deletions cmd/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ var apiCmd = &cobra.Command{

The path argument is an API endpoint, e.g. /v1/environments/{environment_id}/campaigns.
Placeholders like {environment_id} are substituted from --params. The HTTP method
defaults to GET (or POST if --json is provided); override with -X/--method.
defaults to GET (or POST if --json or --file is provided); override with -X/--method.

Endpoints that take a file accept it through --file, which sends the request as
multipart/form-data. --json then supplies the request's other form fields instead
of a JSON body. Large uploads can outrun the default 30s budget; raise it with
--timeout.

All standard flags work: --jq, --dry-run, --page-all, --page, --limit.

Expand All @@ -34,13 +39,15 @@ Examples:
cio api /v1/environments/{environment_id}/campaigns/{campaign_id} --params '{"environment_id": "456", "campaign_id": "789"}'
cio api /v1/environments/{environment_id}/campaigns -X POST --params '{"environment_id": "456"}' --json '{"campaign": {"name": "Test"}}'
cio api /v1/accounts/{account_id} --params '{"account_id": "123"}'
cio api /v1/environments/{environment_id}/segments --params '{"environment_id": "456"}' --dry-run`,
cio api /v1/environments/{environment_id}/segments --params '{"environment_id": "456"}' --dry-run
cio api /v1/environments/{environment_id}/knowledge_source_library/upload --params '{"environment_id": "456"}' --file @runbook.md --json '{"name": "Ops runbook"}' --timeout 120s`,
Args: cobra.ExactArgs(1),
RunE: runAPI,
}

func init() {
apiCmd.Flags().StringP("method", "X", "", "HTTP method (default: GET, or POST if --json is provided)")
apiCmd.Flags().StringP("method", "X", "", "HTTP method (default: GET, or POST if --json or --file is provided)")
apiCmd.Flags().StringArray("file", nil, "Send the request as multipart/form-data with a file part: --file @path, or --file field=@path to name the part (repeatable). --json then supplies the request's other form fields")
rootCmd.AddCommand(apiCmd)
}

Expand All @@ -67,7 +74,13 @@ func runAPI(cmd *cobra.Command, args []string) error {
return err
}

httpMethod := resolveMethod(methodFlag, jsonBody)
fileParts, err := GetFileParts(cmd)
if err != nil {
output.PrintError(output.CodeValidationError, err.Error(), nil)
return err
}

httpMethod := resolveMethod(methodFlag, jsonBody != nil || len(fileParts) > 0)

// Parse --params: separate path params from query params.
paramsRaw, _ := cmd.Flags().GetString("params")
Expand Down Expand Up @@ -102,19 +115,31 @@ func runAPI(cmd *cobra.Command, args []string) error {

jq := GetJQFlag(cmd)

// Ahead of the dry run: reporting "valid" for a combination the real run
// rejects is worse than no check.
if _, _, pageAllFlag := GetPaginationFlags(cmd); pageAllFlag && len(fileParts) > 0 {
err := fmt.Errorf("--page-all cannot be combined with --file")
output.PrintError(output.CodeValidationError, err.Error(), nil)
return err
}

// Dry run.
if GetDryRun(cmd) {
apiURL, _ := cmd.Flags().GetString("api-url")
if apiURL == "" {
apiURL = c.BaseURL()
}
contentType := "application/json"
if len(fileParts) > 0 {
contentType = "multipart/form-data"
}
dryRun := map[string]any{
"dry_run": true,
"method": httpMethod,
"url": apiURL + resolvedPath,
"headers": map[string]string{
"Authorization": "Bearer [REDACTED]",
"Content-Type": "application/json",
"Content-Type": contentType,
},
"validation": map[string]any{
"valid": true,
Expand All @@ -124,7 +149,18 @@ func runAPI(cmd *cobra.Command, args []string) error {
if len(queryParams) > 0 {
dryRun["params"] = queryParams
}
if jsonBody != nil {
if len(fileParts) > 0 {
// Names and sizes only — a dry run must not spill file contents.
dryRun["files"] = filePartsSummary(fileParts)
fields, err := formFieldsFromJSON(jsonBody)
if err != nil {
output.PrintError(output.CodeValidationError, err.Error(), nil)
return err
}
if len(fields) > 0 {
dryRun["fields"] = fields
}
} else if jsonBody != nil {
dryRun["body"] = json.RawMessage(jsonBody)
}
return output.FprintJSON(cmd.OutOrStdout(), dryRun)
Expand All @@ -146,20 +182,40 @@ func runAPI(cmd *cobra.Command, args []string) error {
return doPageAll(cmd, c, resolvedPath, queryParams, page, limit)
}

result, err := c.Do(cmd.Context(), httpMethod, resolvedPath, queryParams, jsonBody)
body, err := requestBody(jsonBody, fileParts)
if err != nil {
output.PrintError(output.CodeValidationError, err.Error(), nil)
return err
}

result, err := c.DoWithBody(cmd.Context(), httpMethod, resolvedPath, queryParams, body)
if err != nil {
return handleAPIError(err)
}

return output.FprintProcess(cmd.OutOrStdout(), result, jq, GetRawFlag(cmd))
}

func requestBody(jsonBody json.RawMessage, fileParts []client.FilePart) (*client.Body, error) {
if len(fileParts) == 0 {
if jsonBody == nil {
return nil, nil
}
return &client.Body{ContentType: "application/json", Bytes: jsonBody}, nil
}
fields, err := formFieldsFromJSON(jsonBody)
if err != nil {
return nil, err
}
return client.NewMultipartBody(fileParts, fields)
}

// resolveMethod determines the HTTP method from the flag or defaults.
func resolveMethod(flag string, body []byte) string {
func resolveMethod(flag string, hasBody bool) string {
if flag != "" {
return strings.ToUpper(flag)
}
if body != nil {
if hasBody {
return "POST"
}
return "GET"
Expand Down
143 changes: 143 additions & 0 deletions cmd/api_upload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package cmd

import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"

"github.com/customerio/cli/internal/client"
"github.com/spf13/cobra"
)

// Matches the field every upload endpoint names its file part.
const defaultFilePartField = "file"

// [] is the conventional encoding for a repeated (multi-file) field.
var filePartFieldRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+(\[\])?$`)

func GetFileParts(cmd *cobra.Command) ([]client.FilePart, error) {
bindings, _ := cmd.Flags().GetStringArray("file")
if len(bindings) == 0 {
return nil, nil
}

parts := make([]client.FilePart, 0, len(bindings))
seen := make(map[string]bool, len(bindings))
for _, binding := range bindings {
field, path, err := splitFileBinding(binding)
if err != nil {
return nil, err
}
if seen[field] && !strings.HasSuffix(field, "[]") {
return nil, fmt.Errorf("--file %s: field %q given more than once; name it %s[] to send several files under one field", binding, field, field)
}
seen[field] = true

content, err := readUploadFile(path)
if err != nil {
return nil, fmt.Errorf("--file %s: %w", binding, err)
}
parts = append(parts, client.FilePart{
Field: field,
Filename: filepath.Base(path),
Content: content,
})
}
return parts, nil
}

// Bounded: a bare os.ReadFile would pull a mistyped path at a huge file entirely
// into memory only to reject it for size.
func readUploadFile(path string) ([]byte, error) {
fh, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = fh.Close() }()

content, err := io.ReadAll(io.LimitReader(fh, client.MaxUploadBytes+1))
if err != nil {
return nil, err
}
if len(content) == 0 {
return nil, fmt.Errorf("file is empty")
}
if len(content) > client.MaxUploadBytes {
return nil, fmt.Errorf("file is over the %d byte upload limit", client.MaxUploadBytes)
}
return content, nil
}

func splitFileBinding(binding string) (field, path string, err error) {
if binding == "" {
return "", "", fmt.Errorf("--file: missing value, expected [field=]@path")
}
// A leading @ means the whole value is a path, so a filename containing '='
// is not mistaken for a field binding.
if rest, ok := strings.CutPrefix(binding, "@"); ok {
field, path = defaultFilePartField, rest
} else if name, value, found := strings.Cut(binding, "="); found {
field, path = name, strings.TrimPrefix(value, "@")
} else {
field, path = defaultFilePartField, binding
}

if !filePartFieldRegex.MatchString(field) {
return "", "", fmt.Errorf("--file %s: field name %q may use letters, digits, underscores and hyphens, with an optional trailing [] for a repeated field", binding, field)
}
if path == "" {
return "", "", fmt.Errorf("--file %s: missing filename", binding)
}
return field, path, nil
}

// Reusing --json spares an upload a second flag for its metadata. Scalars only:
// a nested value has no unambiguous form representation.
func formFieldsFromJSON(body json.RawMessage) (map[string]string, error) {
if len(body) == 0 {
return nil, nil
}
// UseNumber, not the default float64: a resource ID like 1234567890123456789
// would otherwise be sent as 1234567890123456800 — wrong but plausible.
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var raw map[string]any
if err := dec.Decode(&raw); err != nil {
return nil, fmt.Errorf("--json must be an object when --file is used: %w", err)
}
fields := make(map[string]string, len(raw))
for name, v := range raw {
switch value := v.(type) {
case string:
fields[name] = value
case bool:
fields[name] = strconv.FormatBool(value)
case json.Number:
fields[name] = value.String()
case nil:
fields[name] = ""
default:
return nil, fmt.Errorf("--json field %q is not a string, number or boolean; a multipart request cannot carry nested values", name)
}
}
return fields, nil
}

// Names and sizes only: a dry run must not echo file contents.
func filePartsSummary(parts []client.FilePart) []map[string]any {
out := make([]map[string]any, 0, len(parts))
for _, p := range parts {
out = append(out, map[string]any{
"field": p.Field,
"filename": p.Filename,
"size": len(p.Content),
})
}
return out
}
Loading