Files
multica/server/pkg/composio/connected_accounts.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

199 lines
6.9 KiB
Go

package composio
import (
"context"
"errors"
"net/http"
"net/url"
"strconv"
)
// --- Create link --------------------------------------------------------
// CreateLinkRequest is the body of POST /connected_accounts/link.
//
// Spec: https://docs.composio.dev/reference/api-reference/connected-accounts/postConnectedAccountsLink
type CreateLinkRequest struct {
// AuthConfigID is the `ac_…` id of an auth config registered in your
// Composio project (one per toolkit / OAuth client variant).
AuthConfigID string `json:"auth_config_id"`
// UserID is your own user identifier — Composio scopes the resulting
// connected account by it.
UserID string `json:"user_id"`
// CallbackURL is where Composio sends the user after they finish the
// hosted auth flow. Optional; Composio has a default landing page.
CallbackURL string `json:"callback_url,omitempty"`
// Alias is a human-readable label for the connection. Optional but useful
// when the same user connects multiple accounts of the same toolkit.
Alias string `json:"alias,omitempty"`
// ConnectionData lets the caller pre-fill connection fields with default
// values (per the Composio docs). Free-form to avoid coupling to the
// scheme-specific child schemas.
ConnectionData map[string]any `json:"connection_data,omitempty"`
}
// CreateLinkResponse is the body returned by POST /connected_accounts/link.
type CreateLinkResponse struct {
LinkToken string `json:"link_token"`
RedirectURL string `json:"redirect_url"`
ExpiresAt string `json:"expires_at"`
ConnectedAccountID string `json:"connected_account_id"`
}
// CreateLink starts a hosted Composio Connect Link session. The redirect URL
// is what the caller should send the user to (popup, redirect, or
// SFSafariViewController).
func (c *Client) CreateLink(ctx context.Context, req CreateLinkRequest) (*CreateLinkResponse, error) {
if req.AuthConfigID == "" {
return nil, errors.New("composio: CreateLink: AuthConfigID is required")
}
if req.UserID == "" {
return nil, errors.New("composio: CreateLink: UserID is required")
}
var out CreateLinkResponse
if err := c.do(c.newRequest(ctx).SetBody(req), http.MethodPost, "/connected_accounts/link", &out); err != nil {
return nil, err
}
return &out, nil
}
// --- List ---------------------------------------------------------------
// ListConnectedAccountsRequest collects the optional filters supported by
// GET /connected_accounts. Zero values are omitted from the query string.
//
// Per the Composio v3.1 spec all filters are plural array params: the SDK
// sends one query entry per slice element (`user_ids=u1&user_ids=u2`).
// Pass a single-element slice for the common "list by one user" case.
//
// Spec: https://docs.composio.dev/reference/api-reference/connected-accounts/getConnectedAccounts
type ListConnectedAccountsRequest struct {
UserIDs []string
ToolkitSlugs []string
AuthConfigIDs []string
ConnectedAccountIDs []string
Statuses []string // ACTIVE, EXPIRED, INACTIVE, …
OrderBy string // "created_at" (default) | "updated_at"
OrderDirection string // "asc" | "desc" (default)
AccountType string // experimental: PRIVATE | SHARED | ALL
Limit int // 0 = use upstream default
Cursor string
}
// ConnectedAccount mirrors a subset of the Composio response shape. Only the
// fields actually consumed by the MVP are typed; extras live in Extra so
// callers can read them without an SDK update.
type ConnectedAccount struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AuthConfigID string `json:"auth_config_id"`
Toolkit Toolkit `json:"toolkit"`
Status string `json:"status"`
StatusReason string `json:"status_reason,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
LastUsedAt string `json:"last_used_at,omitempty"`
Extra map[string]any `json:"-"`
}
// ListConnectedAccountsResponse is the typed paginated response.
type ListConnectedAccountsResponse struct {
Items []ConnectedAccount `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
TotalItems int `json:"total_items,omitempty"`
}
// ListConnectedAccounts returns the connections matching the supplied filters.
func (c *Client) ListConnectedAccounts(ctx context.Context, req ListConnectedAccountsRequest) (*ListConnectedAccountsResponse, error) {
q := url.Values{}
for _, v := range req.UserIDs {
if v != "" {
q.Add("user_ids", v)
}
}
for _, v := range req.ToolkitSlugs {
if v != "" {
q.Add("toolkit_slugs", v)
}
}
for _, v := range req.AuthConfigIDs {
if v != "" {
q.Add("auth_config_ids", v)
}
}
for _, v := range req.ConnectedAccountIDs {
if v != "" {
q.Add("connected_account_ids", v)
}
}
for _, v := range req.Statuses {
if v != "" {
q.Add("statuses", v)
}
}
if req.OrderBy != "" {
q.Set("order_by", req.OrderBy)
}
if req.OrderDirection != "" {
q.Set("order_direction", req.OrderDirection)
}
if req.AccountType != "" {
q.Set("account_type", req.AccountType)
}
if req.Limit > 0 {
q.Set("limit", strconv.Itoa(req.Limit))
}
if req.Cursor != "" {
q.Set("cursor", req.Cursor)
}
path := "/connected_accounts"
if encoded := q.Encode(); encoded != "" {
path += "?" + encoded
}
var out ListConnectedAccountsResponse
if err := c.do(c.newRequest(ctx), http.MethodGet, path, &out); err != nil {
return nil, err
}
return &out, nil
}
// --- Revoke / Delete ----------------------------------------------------
// RevokeConnection revokes the OAuth grant at the upstream provider but
// keeps the Composio record. Use this when the user disconnects and you
// want the provider-side tokens invalidated immediately.
func (c *Client) RevokeConnection(ctx context.Context, connectedAccountID string) error {
if connectedAccountID == "" {
return errors.New("composio: RevokeConnection: connectedAccountID is required")
}
return c.do(c.newRequest(ctx),
http.MethodPost, "/connected_accounts/"+url.PathEscape(connectedAccountID)+"/revoke", nil)
}
// DeleteConnectedAccount removes the connection record from Composio. The
// provider tokens are NOT revoked by this call — call [Client.RevokeConnection]
// first if you need them invalidated upstream.
//
// Returns nil for 404 so callers can treat the operation as idempotent.
func (c *Client) DeleteConnectedAccount(ctx context.Context, connectedAccountID string) error {
if connectedAccountID == "" {
return errors.New("composio: DeleteConnectedAccount: connectedAccountID is required")
}
err := c.do(c.newRequest(ctx),
http.MethodDelete, "/connected_accounts/"+url.PathEscape(connectedAccountID), nil)
if err == nil {
return nil
}
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.IsNotFound() {
return nil
}
return err
}