mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +02:00
* 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>
199 lines
6.9 KiB
Go
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
|
|
}
|