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
30 changes: 13 additions & 17 deletions cmd/heygen/video_list.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package main

import (
"fmt"
"net/url"
"strconv"

"github.com/heygen-com/heygen-cli/internal/client"
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
"github.com/heygen-com/heygen-cli/internal/command"
"github.com/spf13/cobra"
)

Expand All @@ -19,32 +18,29 @@ func newVideoListCmd(ctx *cmdContext) *cobra.Command {
Short: "List videos",
Long: "List videos in your HeyGen account with optional filtering.",
RunE: func(cmd *cobra.Command, args []string) error {
// Validate --limit if explicitly provided
if cmd.Flags().Changed("limit") {
if limit < 1 || limit > 100 {
return clierrors.NewUsage(fmt.Sprintf("--limit must be between 1 and 100, got %d", limit))
}
}

spec := client.RequestSpec{
spec := &command.Spec{
Endpoint: "/v3/videos",
Method: "GET",
Paginated: true,
DataField: "data",
TokenField: "next_token",
DataField: "data",
}

inv := &command.Invocation{
PathParams: make(map[string]string),
QueryParams: make(url.Values),
}

if cmd.Flags().Changed("limit") {
spec.QueryParams = append(spec.QueryParams, client.QueryParam{Key: "limit", Value: strconv.Itoa(limit)})
inv.QueryParams.Set("limit", strconv.Itoa(limit))
}
if token != "" {
spec.QueryParams = append(spec.QueryParams, client.QueryParam{Key: "token", Value: token})
inv.QueryParams.Set("token", token)
}
if folderID != "" {
spec.QueryParams = append(spec.QueryParams, client.QueryParam{Key: "folder_id", Value: folderID})
inv.QueryParams.Set("folder_id", folderID)
}

result, err := ctx.client.Execute(spec)
result, err := ctx.client.Execute(spec, inv)
if err != nil {
return err
}
Expand Down
18 changes: 13 additions & 5 deletions cmd/heygen/video_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,15 @@ func TestVideoList_Flags(t *testing.T) {
}

func TestVideoList_LimitOutOfRange(t *testing.T) {
srv := setupTestServer(t, map[string]testHandler{})
// The OpenAPI spec doesn't define min/max for limit, so validation
// is server-side. The API returns 400 (exit 1), not a client-side
// usage error (exit 2).
srv := setupTestServer(t, map[string]testHandler{
"GET /v3/videos": {
StatusCode: 400,
Body: `{"error":{"code":"invalid_parameter","message":"limit must be between 1 and 100"}}`,
},
})
defer srv.Close()

tests := []struct {
Expand All @@ -156,16 +164,16 @@ func TestVideoList_LimitOutOfRange(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
res := runCommand(t, srv.URL, "test-key", "video", "list", "--limit", tt.limit)

if res.ExitCode != clierrors.ExitUsage {
t.Errorf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitUsage, res.Stderr)
if res.ExitCode != clierrors.ExitGeneral {
t.Errorf("ExitCode = %d, want %d\nstderr: %s", res.ExitCode, clierrors.ExitGeneral, res.Stderr)
}

var envelope map[string]map[string]any
if err := json.Unmarshal([]byte(res.Stderr), &envelope); err != nil {
t.Fatalf("stderr is not valid JSON: %v\nstderr: %s", err, res.Stderr)
}
if envelope["error"]["code"] != "usage_error" {
t.Errorf("error.code = %v, want %q", envelope["error"]["code"], "usage_error")
if envelope["error"]["code"] != "invalid_parameter" {
t.Errorf("error.code = %v, want %q", envelope["error"]["code"], "invalid_parameter")
}
})
}
Expand Down
58 changes: 21 additions & 37 deletions internal/client/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,44 +9,46 @@ import (
"net/url"
"strings"

"github.com/heygen-com/heygen-cli/internal/command"
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
)

// Execute converts a RequestSpec into an HTTP request, sends it, and returns
// the raw JSON response body. Errors are returned as *CLIError with the
// appropriate exit code and any X-Request-Id from the response.
func (c *Client) Execute(spec RequestSpec) (json.RawMessage, error) {
// Execute sends an HTTP request described by the Spec (static metadata)
// and Invocation (resolved user values). Returns the raw JSON response.
//
// The Spec provides the endpoint template, HTTP method, body encoding,
// and behavioral flags (pagination, polling). The Invocation provides
// the concrete path params, query params, body, and file path.
func (c *Client) Execute(spec *command.Spec, inv *command.Invocation) (json.RawMessage, error) {
if spec.BodyEncoding == "multipart" {
return nil, clierrors.NewUsage("multipart upload is not yet implemented")
}

// Build URL
reqURL, err := buildURL(c.baseURL, spec.Endpoint, spec.PathParams, spec.QueryParams)
if spec.Method == "" {
return nil, clierrors.New("Spec.Method must be set")
}

// Build URL from endpoint template + path params + query params
reqURL, err := buildURL(c.baseURL, spec.Endpoint, inv.PathParams, inv.QueryParams)
if err != nil {
return nil, clierrors.New(fmt.Sprintf("failed to build request URL: %v", err))
}

// Build body
// Build body — only if Invocation has body content
var body io.Reader
if len(spec.Body) > 0 {
bodyMap := fieldSpecsToMap(spec.Body)
data, marshalErr := json.Marshal(bodyMap)
if inv.Body != nil {
data, marshalErr := json.Marshal(inv.Body)
if marshalErr != nil {
return nil, clierrors.New(fmt.Sprintf("failed to marshal request body: %v", marshalErr))
}
body = bytes.NewReader(data)
}

// Create request
if spec.Method == "" {
return nil, clierrors.New("RequestSpec.Method must be set")
}
req, err := http.NewRequest(spec.Method, reqURL, body)
if err != nil {
return nil, clierrors.New(fmt.Sprintf("failed to create request: %v", err))
}

// Execute
resp, err := c.Do(req)
if err != nil {
return nil, &clierrors.CLIError{
Expand All @@ -62,7 +64,6 @@ func (c *Client) Execute(spec RequestSpec) (json.RawMessage, error) {
return nil, clierrors.New(fmt.Sprintf("failed to read response body: %v", err))
}

// Handle errors
if resp.StatusCode >= 400 {
return nil, parseErrorResponse(resp.StatusCode, respBody, resp.Header.Get("X-Request-Id"))
}
Expand All @@ -71,8 +72,7 @@ func (c *Client) Execute(spec RequestSpec) (json.RawMessage, error) {
}

// buildURL constructs the full URL with path param substitution and query params.
func buildURL(base, endpoint string, pathParams map[string]string, queryParams []QueryParam) (string, error) {
// Substitute path parameters
func buildURL(base, endpoint string, pathParams map[string]string, queryParams url.Values) (string, error) {
path := endpoint
for key, val := range pathParams {
path = strings.ReplaceAll(path, "{"+key+"}", url.PathEscape(val))
Expand All @@ -83,14 +83,11 @@ func buildURL(base, endpoint string, pathParams map[string]string, queryParams [
return "", err
}

// Add query parameters (supports repeated keys)
if len(queryParams) > 0 {
q := u.Query()
for _, p := range queryParams {
if p.Repeated {
q.Add(p.Key, p.Value)
} else {
q.Set(p.Key, p.Value)
for key, vals := range queryParams {
for _, v := range vals {
q.Add(key, v)
}
}
u.RawQuery = q.Encode()
Expand All @@ -99,28 +96,15 @@ func buildURL(base, endpoint string, pathParams map[string]string, queryParams [
return u.String(), nil
}

// fieldSpecsToMap converts a slice of FieldSpec into a JSON-ready map.
func fieldSpecsToMap(fields []FieldSpec) map[string]any {
m := make(map[string]any, len(fields))
for _, f := range fields {
if f.Value != nil {
m[f.Name] = f.Value
}
}
return m
}

// parseErrorResponse parses an API error response into a CLIError.
func parseErrorResponse(statusCode int, body []byte, requestID string) *clierrors.CLIError {
// Try to parse the standard error envelope: {"error": {...}}
var envelope struct {
Error clierrors.APIError `json:"error"`
}
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Error.Message != "" {
return clierrors.FromAPIError(statusCode, &envelope.Error, requestID)
}

// Fallback: couldn't parse error envelope
return &clierrors.CLIError{
Code: "error",
Message: fmt.Sprintf("API returned HTTP %d", statusCode),
Expand Down
Loading
Loading