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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ chatwoot contact 456 conversations

chatwoot inboxes / agents / labels / teams # List
chatwoot me # Your profile

chatwoot api /conversations/123 # Expands to /api/v1/accounts/<id>/conversations/123
chatwoot api -X PATCH /conversations/123 --data '{"status":"open"}'
```

Run `chatwoot --help` or see the [full command reference](https://developers.chatwoot.com/cli/commands).
Expand Down
138 changes: 138 additions & 0 deletions internal/cmd/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cmd

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/textproto"
"net/url"
"os"
"strings"
)

type ApiCmd struct {
Method string `short:"X" placeholder:"METHOD" help:"HTTP method. Defaults to POST with --data, otherwise GET."`
Data string `short:"d" placeholder:"JSON|@FILE" help:"JSON request body, or @file to read body from a file."`
Header []string `short:"H" placeholder:"HEADER" help:"Additional request header, as 'Name: value'. Repeatable."`
Exact bool `help:"Use the path exactly as provided under the configured base URL."`
Path string `arg:"" help:"Endpoint path. /conversations/123 expands under the configured account."`
}

func (c *ApiCmd) Help() string {
return `Account-relative paths such as /conversations/123 are expanded under
/api/v1/accounts/<account_id>. Use --exact for non-account-scoped paths.

The default method is GET, or POST when --data is provided. Override with -X.

Examples:
chatwoot api /conversations/123
chatwoot api -X PATCH /conversations/123 --data '{"status":"open"}'
chatwoot api --exact /api/v1/profile`
}

func (c *ApiCmd) Run(app *App) error {
method := strings.ToUpper(c.Method)
if method == "" {
if c.Data != "" {
method = http.MethodPost
} else {
method = http.MethodGet
}
}

path, accountScoped, err := normalizeAPIPath(c.Path, c.Exact)
if err != nil {
return err
}

body, err := apiRequestBody(c.Data)
if err != nil {
return err
}

headers, err := parseAPIHeaders(c.Header)
if err != nil {
return err
}

resp, err := app.Client.RequestRaw(method, path, body, accountScoped, headers)
if err != nil {
return err
}

printAPIResponse(app.Printer.Writer, resp.Body)
return nil
}

func normalizeAPIPath(path string, exact bool) (string, bool, error) {
if strings.TrimSpace(path) == "" {
return "", false, fmt.Errorf("path is required")
}

parsed, err := url.Parse(path)
if err != nil {
return "", false, fmt.Errorf("invalid path: %w", err)
}
if parsed.IsAbs() {
return "", false, fmt.Errorf("absolute URLs are not supported; pass a path under the configured Chatwoot base URL")
}

if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if exact || strings.HasPrefix(path, "/api/") {
return path, false, nil
}
return path, true, nil
}

func apiRequestBody(data string) (io.Reader, error) {
if data == "" {
return nil, nil
}
if !strings.HasPrefix(data, "@") {
return strings.NewReader(data), nil
}
if data == "@-" {
return os.Stdin, nil
}

body, err := os.ReadFile(strings.TrimPrefix(data, "@"))
if err != nil {
return nil, fmt.Errorf("read request body: %w", err)
}
return bytes.NewReader(body), nil
}

func parseAPIHeaders(headerArgs []string) (http.Header, error) {
headers := make(http.Header)
for _, arg := range headerArgs {
name, value, ok := strings.Cut(arg, ":")
if !ok || strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("invalid header %q; expected 'Name: value'", arg)
}
headers.Add(textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name)), strings.TrimSpace(value))
}
return headers, nil
}

func printAPIResponse(w io.Writer, body []byte) {
if len(strings.TrimSpace(string(body))) == 0 {
return
}

var decoded any
if err := json.Unmarshal(body, &decoded); err == nil {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(decoded)
return
}

_, _ = w.Write(body)
if body[len(body)-1] != '\n' {
_, _ = fmt.Fprintln(w)
}
}
130 changes: 130 additions & 0 deletions internal/cmd/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package cmd

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/chatwoot/cli/internal/config"
)

func TestApiCmdCallsAccountScopedEndpoint(t *testing.T) {
setupTestEnv(t)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/accounts/1/conversations/123" {
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
return
}
if r.Header.Get("api_access_token") != "test-token" {
t.Errorf("api_access_token = %q, want test-token", r.Header.Get("api_access_token"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":123,"status":"open"}`))
}))
defer server.Close()

if err := config.Save(&config.Config{BaseURL: server.URL, AccountID: 1}); err != nil {
t.Fatalf("config.Save: %v", err)
}
app, err := NewApp(&CLI{Output: "text"}, false, "test")
if err != nil {
t.Fatalf("NewApp: %v", err)
}

var out bytes.Buffer
app.Printer.Writer = &out

if err := (&ApiCmd{Path: "/conversations/123"}).Run(app); err != nil {
t.Fatalf("Run: %v", err)
}

var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, out.String())
}
if got["id"] != float64(123) || got["status"] != "open" {
t.Fatalf("output = %#v, want conversation JSON", got)
}
}

func TestApiCmdUsesExactAPIPath(t *testing.T) {
setupTestEnv(t)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/profile" {
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":7,"name":"Ada"}`))
}))
defer server.Close()

if err := config.Save(&config.Config{BaseURL: server.URL, AccountID: 1}); err != nil {
t.Fatalf("config.Save: %v", err)
}
app, err := NewApp(&CLI{Output: "text"}, false, "test")
if err != nil {
t.Fatalf("NewApp: %v", err)
}
app.Printer.Writer = &bytes.Buffer{}

if err := (&ApiCmd{Path: "/api/v1/profile"}).Run(app); err != nil {
t.Fatalf("Run: %v", err)
}
}

func TestApiCmdSendsMethodBodyAndHeaders(t *testing.T) {
setupTestEnv(t)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
t.Errorf("method = %s, want PATCH", r.Method)
}
if r.URL.Path != "/api/v1/accounts/1/conversations/123/custom_attributes" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Header.Get("X-Test") != "yes" {
t.Errorf("X-Test header = %q, want yes", r.Header.Get("X-Test"))
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
if strings.TrimSpace(string(body)) != `{"priority":"urgent"}` {
t.Errorf("body = %q", string(body))
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

if err := config.Save(&config.Config{BaseURL: server.URL, AccountID: 1}); err != nil {
t.Fatalf("config.Save: %v", err)
}
app, err := NewApp(&CLI{Output: "text"}, false, "test")
if err != nil {
t.Fatalf("NewApp: %v", err)
}
app.Printer.Writer = &bytes.Buffer{}

err = (&ApiCmd{
Method: "patch",
Path: "conversations/123/custom_attributes",
Data: `{"priority":"urgent"}`,
Header: []string{"X-Test: yes"},
}).Run(app)
if err != nil {
t.Fatalf("Run: %v", err)
}
}

func TestNormalizeAPIPathRejectsAbsoluteURLs(t *testing.T) {
if _, _, err := normalizeAPIPath("https://example.com/api/v1/profile", false); err == nil {
t.Fatal("expected absolute URL error")
}
}
7 changes: 6 additions & 1 deletion internal/cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ func NewApp(cli *CLI, skipAuth bool, version string) (*App, error) {
return nil, fmt.Errorf("not authenticated: %w", err)
}

client := sdk.NewClient(effectiveCfg.BaseURL, apiKey, effectiveCfg.AccountID, sdk.WithVerbose(cli.Verbose))
client := sdk.NewClient(
effectiveCfg.BaseURL,
apiKey,
effectiveCfg.AccountID,
sdk.WithVerbose(cli.Verbose),
)

return &App{
Client: client,
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type CLI struct {
// Workflow. `me` and `whoami` are aliases of `auth status`.
Me MeCmd `cmd:"" help:"Show identity and connection (alias of 'auth status')."`
Whoami WhoamiCmd `cmd:"" help:"Show identity and connection (alias of 'auth status')."`
Api ApiCmd `cmd:"" help:"Make an HTTP request to the Chatwoot API."`

// Setup.
Auth AuthCmd `cmd:"" help:"Login, logout, and status."`
Expand Down
1 change: 0 additions & 1 deletion internal/cmd/conversation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,4 +236,3 @@ func TestConvUnassignPostsZeroAssignee(t *testing.T) {
t.Errorf("posted assignee_id = %v, want 0 (Chatwoot's unassign sentinel)", got.AssigneeID)
}
}

Loading
Loading