Files
multica/server/pkg/composio/client.go
Multica Eve 8d0ea04fb0 feat(composio): add standalone Go SDK client (MVP) (#4603)
* feat(composio): add standalone Go SDK client (MVP)

Adds server/pkg/composio — a self-contained Go SDK for the Composio v3.1
REST API. Built on go-resty/resty v2; zero coupling to other Multica
packages so it can be vendored or extracted later without surgery.

MVP surface (just the endpoints Stage 2 needs):

- POST /connected_accounts/link        Client.CreateLink
- POST /tool_router/session            Client.CreateSession
- GET  /connected_accounts             Client.ListConnectedAccounts
- POST /connected_accounts/{id}/revoke Client.RevokeConnection
- DELETE /connected_accounts/{id}      Client.DeleteConnectedAccount
                                       (404 -> nil, idempotent)
- GET  /toolkits                       Client.ListToolkits
- GET  /toolkits/{slug}                Client.GetToolkit
- POST /tools/execute/{slug}           Client.ExecuteTool
- Webhook HMAC-SHA256 verification     composio.VerifyWebhook /
                                       VerifyHTTPRequest + ParseEvent

Other notes:

- Auth via x-api-key header (Composio v3.1 contract).
- Typed *APIError envelope with IsNotFound / IsUnauthorized /
  IsRateLimited helpers; falls back to raw body when upstream returns
  non-JSON.
- Webhook signature accepts the official "v1,<sig>" format and any
  comma-separated multi-version list; 300s replay tolerance by default,
  honors an injectable clock for tests; RFC3339 timestamps tolerated.
- README.md documents all public APIs and design choices.

Tests:

- All exercise httptest.NewServer - no real Composio calls.
- 36 tests covering happy paths, validation, 404 idempotence, error
  decoding, signature verify (good / tampered / stale / multi-version /
  bare / RFC3339 / missing headers / empty secret).
- go test ./pkg/composio/... -cover -> 82.2%, exceeds the >=80% bar.

Follow-ups (separate PRs):

- server/internal/integrations/composio - DB schema, REST handlers,
  registration_service (CSRF), dispatch hook (MUL-3720 remainder).
- Pagination iterators, retry middleware, proxy execute, triggers.

Refs: MUL-3720, MUL-3715
Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): align SDK with v3.1 wire contract (PR #4603 review)

Addresses GPT-Boy's review on PR #4603:

Must-fix
- ListConnectedAccountsRequest: switch UserID/AuthConfigID singular fields
  to plural slices (UserIDs, AuthConfigIDs, ToolkitSlugs, ConnectedAccountIDs,
  Statuses). The Composio v3.1 spec ships these as `*_ids` array params;
  our singular form silently dropped the user-filter on the wire. Also
  surfaces order_by / order_direction / account_type from the same spec.
- ExecuteToolRequest: rename ToolkitVersions -> Version with json tag
  `version` (the actual v3.1 body field). Marks AllowTracing as
  deprecated per the spec.

Nits
- ListToolkitsRequest.SortBy comment: `popular | alphabetical` -> the
  real enum `usage | alphabetically`.
- Client constructor: when Options.HTTPClient is provided, use
  resty.NewWithClient(hc) so the caller's Transport, Jar, CheckRedirect,
  and Timeout all carry through — the prior code only forwarded
  Transport + Timeout despite the field comment promising the full
  *http.Client.

Tests
- TestListConnectedAccounts_QueryString now asserts plural query keys
  (user_ids, auth_config_ids, connected_account_ids, statuses) and
  explicitly guards that the legacy singular keys do not leak.
- TestExecuteTool_Success decodes the body as a raw map and asserts the
  wire key is `version` (not `toolkit_versions`).
- New TestExecuteToolRequest_VersionSerialization locks the json tag.
- New TestNewClient_HonorsInjectedHTTPClient drives a request through a
  recordingTransport and asserts the inbound *http.Client actually
  handled it.

Verification
- go test ./pkg/composio/... -cover -> 85.1% (38 tests; was 82.2% / 36).
- go vet, go build, gofmt -l all clean.

Refs: PR #4603 review, MUL-3720
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 15:07:47 +08:00

166 lines
4.8 KiB
Go

package composio
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
// DefaultBaseURL is the canonical Composio v3.1 REST root.
const DefaultBaseURL = "https://backend.composio.dev/api/v3.1"
// DefaultUserAgent is sent on every request unless overridden via [Options.UserAgent].
const DefaultUserAgent = "multica-composio-go/0.1"
// DefaultTimeout is the per-request timeout applied to the underlying
// resty client when [Options.Timeout] is zero.
const DefaultTimeout = 30 * time.Second
// Options configures a [Client]. Only APIKey is required.
type Options struct {
// APIKey is the Composio project API key, sent as the `x-api-key` header.
APIKey string
// BaseURL overrides the API root. Mostly useful for tests against a
// httptest.Server. Defaults to [DefaultBaseURL].
BaseURL string
// UserAgent overrides the User-Agent header. Defaults to [DefaultUserAgent].
UserAgent string
// Timeout is the per-request timeout. Zero means [DefaultTimeout].
// A negative value disables the timeout entirely.
Timeout time.Duration
// HTTPClient lets callers inject a custom *http.Client (for example with
// a corporate transport, custom CookieJar, redirect policy, or
// observability instrumentation). When non-nil it is adopted in full
// via resty.NewWithClient — the caller's Transport, Jar, CheckRedirect,
// and built-in Timeout all carry through. If both this client's
// Timeout and [Options.Timeout] are set, [Options.Timeout] wins.
HTTPClient *http.Client
// RetryCount is the number of retries resty performs on transient
// failures. Zero means no retries (callers can layer their own).
RetryCount int
// RetryWaitTime is the base delay between retries when RetryCount > 0.
RetryWaitTime time.Duration
}
// Client is the Composio REST client.
//
// It is safe for concurrent use by multiple goroutines.
type Client struct {
rc *resty.Client
baseURL string
apiKey string
userAgent string
}
// NewClient constructs a Client from [Options]. It returns an error when the
// options are obviously broken (empty API key, malformed base URL).
func NewClient(opts Options) (*Client, error) {
if strings.TrimSpace(opts.APIKey) == "" {
return nil, errors.New("composio: APIKey is required")
}
baseURL := opts.BaseURL
if baseURL == "" {
baseURL = DefaultBaseURL
}
if _, err := url.Parse(baseURL); err != nil {
return nil, fmt.Errorf("composio: invalid BaseURL %q: %w", baseURL, err)
}
baseURL = strings.TrimRight(baseURL, "/")
ua := opts.UserAgent
if ua == "" {
ua = DefaultUserAgent
}
timeout := opts.Timeout
switch {
case timeout == 0:
timeout = DefaultTimeout
case timeout < 0:
timeout = 0 // resty treats 0 as "no timeout"
}
rc := newRestyClient(opts.HTTPClient).
SetBaseURL(baseURL).
SetHeader("Content-Type", "application/json").
SetHeader("Accept", "application/json").
SetHeader("User-Agent", ua).
SetHeader("x-api-key", opts.APIKey).
SetTimeout(timeout)
if opts.RetryCount > 0 {
rc = rc.SetRetryCount(opts.RetryCount)
if opts.RetryWaitTime > 0 {
rc = rc.SetRetryWaitTime(opts.RetryWaitTime)
}
}
return &Client{
rc: rc,
baseURL: baseURL,
apiKey: opts.APIKey,
userAgent: ua,
}, nil
}
// newRestyClient constructs a resty.Client honoring an injected *http.Client
// in full when one is provided. resty.NewWithClient adopts the caller's
// http.Client wholesale — so the caller's Transport, Jar, CheckRedirect,
// and Timeout all carry through — which matches the documented contract
// of [Options.HTTPClient].
func newRestyClient(hc *http.Client) *resty.Client {
if hc != nil {
return resty.NewWithClient(hc)
}
return resty.New()
}
// BaseURL returns the resolved API root after defaulting.
func (c *Client) BaseURL() string { return c.baseURL }
// APIKeyHeader returns the header pair callers should attach to MCP
// streaming clients or any other Composio request made outside the SDK.
//
// Returning a copy keeps the internal map immutable.
func (c *Client) APIKeyHeader() map[string]string {
return map[string]string{"x-api-key": c.apiKey}
}
// newRequest returns a resty.Request bound to the given context.
// All endpoint methods funnel through this helper.
func (c *Client) newRequest(ctx context.Context) *resty.Request {
return c.rc.R().SetContext(ctx)
}
// do executes a request and unmarshals a successful body into out.
// On non-2xx it returns a *APIError populated from the response body.
//
// out may be nil if the caller does not care about the body
// (e.g. DELETE / 204).
func (c *Client) do(req *resty.Request, method, path string, out any) error {
if out != nil {
req = req.SetResult(out)
}
resp, err := req.Execute(method, path)
if err != nil {
return fmt.Errorf("composio: %s %s: %w", method, path, err)
}
if resp.IsError() {
return parseAPIError(resp.StatusCode(), resp.Body())
}
return nil
}