mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +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>
89 lines
2.6 KiB
Go
89 lines
2.6 KiB
Go
package composio
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
)
|
|
|
|
// APIError is the canonical error returned by the SDK when Composio responds
|
|
// with a non-2xx HTTP status.
|
|
//
|
|
// The Composio error envelope as of v3.1 looks like:
|
|
//
|
|
// {
|
|
// "error": {
|
|
// "message": "...",
|
|
// "code": 400,
|
|
// "slug": "INVALID_INPUT",
|
|
// "status": 400,
|
|
// "request_id": "req_...",
|
|
// "suggested_fix":"...",
|
|
// "errors": ["..."]
|
|
// }
|
|
// }
|
|
//
|
|
// HTTPStatus is the transport status as observed locally; the rest mirrors
|
|
// the body if Composio returned one. RawBody is preserved verbatim so
|
|
// callers can log the full upstream response for debugging.
|
|
type APIError struct {
|
|
HTTPStatus int `json:"-"`
|
|
Message string `json:"message,omitempty"`
|
|
Code int `json:"code,omitempty"`
|
|
Slug string `json:"slug,omitempty"`
|
|
Status int `json:"status,omitempty"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
SuggestedFix string `json:"suggested_fix,omitempty"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
RawBody []byte `json:"-"`
|
|
}
|
|
|
|
// Error implements error. It surfaces the upstream status, slug, and message.
|
|
func (e *APIError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
msg := e.Message
|
|
if msg == "" {
|
|
msg = http.StatusText(e.HTTPStatus)
|
|
}
|
|
if e.Slug != "" {
|
|
return fmt.Sprintf("composio: %d %s (%s)", e.HTTPStatus, msg, e.Slug)
|
|
}
|
|
return fmt.Sprintf("composio: %d %s", e.HTTPStatus, msg)
|
|
}
|
|
|
|
// IsNotFound reports whether the error is an HTTP 404 — useful for idempotent
|
|
// delete/revoke flows.
|
|
func (e *APIError) IsNotFound() bool { return e != nil && e.HTTPStatus == http.StatusNotFound }
|
|
|
|
// IsUnauthorized reports whether the error is an HTTP 401.
|
|
func (e *APIError) IsUnauthorized() bool {
|
|
return e != nil && e.HTTPStatus == http.StatusUnauthorized
|
|
}
|
|
|
|
// IsRateLimited reports whether the error is an HTTP 429.
|
|
func (e *APIError) IsRateLimited() bool {
|
|
return e != nil && e.HTTPStatus == http.StatusTooManyRequests
|
|
}
|
|
|
|
// parseAPIError decodes Composio's `{"error": {...}}` envelope. If the body
|
|
// is not the expected shape it returns an APIError carrying just HTTPStatus
|
|
// and RawBody so callers still see something useful.
|
|
func parseAPIError(status int, body []byte) *APIError {
|
|
out := &APIError{HTTPStatus: status, RawBody: body}
|
|
if len(body) == 0 {
|
|
return out
|
|
}
|
|
var wire struct {
|
|
Error APIError `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(body, &wire); err != nil {
|
|
// Body is not the expected envelope — leave RawBody set, message empty.
|
|
return out
|
|
}
|
|
wire.Error.HTTPStatus = status
|
|
wire.Error.RawBody = body
|
|
return &wire.Error
|
|
}
|