All three SDKs now send X-Attesto-Sdk (attesto-<lang>/<version>) and X-Attesto-Protocol (ATTESTO-PROOFSTREAM-001/0.1-alpha) on every request. A new backend ProtocolVersionMiddleware logs both headers (operators can see the SDK/protocol mix in traffic) and, when the protocol header is present on a /v2 request and names a different protocol identifier or major version, answers 426 Upgrade Required with a structured body (error/supported/received/hint). Absent or unparseable headers change nothing — old clients and curl stay fully compatible (test-asserted, including /v1 never being handshake-gated). SDKs surface the 426 as a typed error: Python AttestoProtocolMismatch, TypeScript AttestoProtocolMismatch, Go IsProtocolMismatch(err) over *APIError (Go-idiomatic). Tests cover the mismatch rules, the 426 mapping, and that the handshake headers are actually sent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
669 lines
23 KiB
Go
669 lines
23 KiB
Go
package attesto
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
bearer string
|
|
httpClient *http.Client
|
|
maxRetries int
|
|
userAgent string
|
|
validateKey bool
|
|
headStore HeadStore
|
|
}
|
|
|
|
type Option func(*Client) error
|
|
|
|
var apiKeyPattern = regexp.MustCompile(`^atto_(?:live|test)_[0-9a-f]{32}$`)
|
|
|
|
func NewClient(apiKey string, opts ...Option) (*Client, error) {
|
|
if !apiKeyPattern.MatchString(apiKey) {
|
|
return nil, errors.New("api key must match atto_live_<32 lowercase hex chars> or atto_test_<32 lowercase hex chars>")
|
|
}
|
|
return newClient(apiKey, true, opts...)
|
|
}
|
|
|
|
func NewBearerClient(token string, opts ...Option) (*Client, error) {
|
|
if strings.TrimSpace(token) == "" {
|
|
return nil, errors.New("bearer token must not be blank")
|
|
}
|
|
return newClient(token, false, opts...)
|
|
}
|
|
|
|
func newClient(bearer string, validateKey bool, opts ...Option) (*Client, error) {
|
|
client := &Client{
|
|
baseURL: DefaultBaseURL,
|
|
bearer: bearer,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
maxRetries: 3,
|
|
userAgent: "attesto-go/" + SDKVersion,
|
|
validateKey: validateKey,
|
|
headStore: NewMemoryHeadStore(),
|
|
}
|
|
for _, opt := range opts {
|
|
if err := opt(client); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if _, err := url.ParseRequestURI(client.baseURL); err != nil {
|
|
return nil, fmt.Errorf("base url must be an absolute URL: %w", err)
|
|
}
|
|
client.baseURL = strings.TrimRight(client.baseURL, "/")
|
|
return client, nil
|
|
}
|
|
|
|
func WithBaseURL(baseURL string) Option {
|
|
return func(c *Client) error {
|
|
baseURL = strings.TrimSpace(strings.TrimRight(baseURL, "/"))
|
|
if baseURL == "" {
|
|
return errors.New("base url must not be blank")
|
|
}
|
|
parsed, err := url.Parse(baseURL)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return errors.New("base url must be an absolute URL")
|
|
}
|
|
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
|
return errors.New("base url must use http or https")
|
|
}
|
|
c.baseURL = baseURL
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithHTTPClient(httpClient *http.Client) Option {
|
|
return func(c *Client) error {
|
|
if httpClient == nil {
|
|
return errors.New("http client must not be nil")
|
|
}
|
|
c.httpClient = httpClient
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithMaxRetries(maxRetries int) Option {
|
|
return func(c *Client) error {
|
|
if maxRetries < 1 {
|
|
return errors.New("max retries must be at least 1")
|
|
}
|
|
c.maxRetries = maxRetries
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithUserAgent(userAgent string) Option {
|
|
return func(c *Client) error {
|
|
if strings.TrimSpace(userAgent) == "" {
|
|
return errors.New("user agent must not be blank")
|
|
}
|
|
c.userAgent = userAgent
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithHeadStore sets the store used for client-side fork detection. The default
|
|
// is an in-memory store; pass NewFileHeadStore(path) to persist across process
|
|
// invocations, or nil to disable fork detection.
|
|
func WithHeadStore(store HeadStore) Option {
|
|
return func(c *Client) error {
|
|
c.headStore = store
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, options ...RequestOptions) (*Stream, error) {
|
|
var out Stream
|
|
if input.Metadata == nil {
|
|
input.Metadata = M{}
|
|
}
|
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams", nil, input, idempotency(options), &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) GetStreamHead(ctx context.Context, streamID string) (*StreamHead, error) {
|
|
var out StreamHead
|
|
err := c.requestJSON(ctx, http.MethodGet, "/v2/streams/"+url.PathEscape(streamID)+"/head", nil, nil, "", &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) ListTenantStreams(ctx context.Context, systemID string, limit, offset int) ([]TenantStream, error) {
|
|
values := url.Values{}
|
|
if systemID != "" {
|
|
values.Set("systemId", systemID)
|
|
}
|
|
setPaging(values, limit, offset)
|
|
var out []TenantStream
|
|
err := c.requestJSON(ctx, http.MethodGet, "/v2/tenant/streams", values, nil, "", &out)
|
|
return out, err
|
|
}
|
|
|
|
func (c *Client) GetTenantStream(ctx context.Context, streamID string) (*TenantStream, error) {
|
|
offset := 0
|
|
for {
|
|
streams, err := c.ListTenantStreams(ctx, "", 500, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, stream := range streams {
|
|
if stream.StreamID == streamID {
|
|
return &stream, nil
|
|
}
|
|
}
|
|
if len(streams) < 500 {
|
|
return nil, fmt.Errorf("stream not found: %s", streamID)
|
|
}
|
|
offset += 500
|
|
}
|
|
}
|
|
|
|
func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput, options ...RequestOptions) (*EventReceipt, error) {
|
|
if err := normalizeEventInput(&input); err != nil {
|
|
return nil, err
|
|
}
|
|
// Reject commitment-unsafe numbers locally so the developer sees the rule at
|
|
// dev time rather than as a production 422; SkipPreflight defers to the server.
|
|
if !skipPreflight(options) {
|
|
if err := AssertCommitmentSafeNumbers(input.Payload, "$.payload"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := AssertCommitmentSafeNumbers(input.Metadata, "$.metadata"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
var out EventReceipt
|
|
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := c.trackHead(out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// trackHead checks the receipt extends the stored head (returning a
|
|
// *ForkDetectedError on a rewind/divergence) and advances the store. No-op when
|
|
// fork detection is disabled (headStore is nil).
|
|
func (c *Client) trackHead(receipt EventReceipt) error {
|
|
if c.headStore == nil {
|
|
return nil
|
|
}
|
|
return checkAndAdvanceHead(c.headStore, receipt)
|
|
}
|
|
|
|
func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventInput, options ...RequestOptions) (*EventBatchResponse, error) {
|
|
if len(events) > 1000 {
|
|
return nil, errors.New("max 1000 events per batch")
|
|
}
|
|
preflight := !skipPreflight(options)
|
|
for i := range events {
|
|
if err := normalizeEventInput(&events[i]); err != nil {
|
|
return nil, fmt.Errorf("event %d: %w", i, err)
|
|
}
|
|
if preflight {
|
|
if err := AssertCommitmentSafeNumbers(events[i].Payload, fmt.Sprintf("$.events[%d].payload", i)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := AssertCommitmentSafeNumbers(events[i].Metadata, fmt.Sprintf("$.events[%d].metadata", i)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
body := M{"events": events}
|
|
var out EventBatchResponse
|
|
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, receipt := range out.Receipts {
|
|
if err := c.trackHead(receipt); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *Client) GetReceipt(ctx context.Context, streamEventID string) (*EventReceipt, error) {
|
|
var out EventReceipt
|
|
err := c.requestJSON(ctx, http.MethodGet, "/v2/receipts/"+url.PathEscape(streamEventID), nil, nil, "", &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) GetWindow(ctx context.Context, windowID string) (M, error) {
|
|
return c.getObject(ctx, "/v2/windows/"+url.PathEscape(windowID), nil)
|
|
}
|
|
|
|
func (c *Client) GetCheckpoint(ctx context.Context, checkpointID string) (M, error) {
|
|
return c.getObject(ctx, "/v2/checkpoints/"+url.PathEscape(checkpointID), nil)
|
|
}
|
|
|
|
func (c *Client) GetCheckpointConsistency(ctx context.Context, checkpointID, fromCheckpointID string) (M, error) {
|
|
values := url.Values{"from": []string{fromCheckpointID}}
|
|
return c.getObject(ctx, "/v2/checkpoints/"+url.PathEscape(checkpointID)+"/consistency", values)
|
|
}
|
|
|
|
func (c *Client) GetWitnessPolicy(ctx context.Context, policyID string) (*WitnessPolicy, error) {
|
|
var out WitnessPolicy
|
|
err := c.requestJSON(ctx, http.MethodGet, "/v2/witness/policies/"+url.PathEscape(policyID), nil, nil, "", &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) GetAnchorEpoch(ctx context.Context, anchorEpochID string) (M, error) {
|
|
return c.getObject(ctx, "/v2/anchors/"+url.PathEscape(anchorEpochID), nil)
|
|
}
|
|
|
|
func (c *Client) GetIVCEpoch(ctx context.Context, ivcEpochID string) (M, error) {
|
|
return c.getObject(ctx, "/v2/ivc/epochs/"+url.PathEscape(ivcEpochID), nil)
|
|
}
|
|
|
|
func (c *Client) BuildVerifierBundle(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
|
|
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
|
|
var out VerifierBundle
|
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/audit/packs", nil, body, idempotency(options), &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) VerifyReceiptRemote(ctx context.Context, input ReceiptVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
|
|
var out VerifyReport
|
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/verify/receipt", nil, input, idempotency(options), &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) VerifyObjectRemote(ctx context.Context, input OfflineVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
|
|
var out VerifyReport
|
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/verify", nil, input, idempotency(options), &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) ListTenantStreamEvents(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
|
|
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/events", limit, offset)
|
|
}
|
|
|
|
func (c *Client) ListTenantWindows(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
|
|
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/windows", limit, offset)
|
|
}
|
|
|
|
func (c *Client) ListTenantCheckpoints(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
|
|
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/checkpoints", limit, offset)
|
|
}
|
|
|
|
func (c *Client) ListForkEvidence(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
|
|
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/forks", limit, offset)
|
|
}
|
|
|
|
func (c *Client) GetTenantProofState(ctx context.Context, streamID string) (M, error) {
|
|
return c.getObject(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/proof-state", nil)
|
|
}
|
|
|
|
func (c *Client) ListTenantIVCEpochs(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
|
|
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/ivc/epochs", limit, offset)
|
|
}
|
|
|
|
func (c *Client) BuildTenantAuditPack(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
|
|
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
|
|
var out VerifierBundle
|
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/audit/packs", nil, body, idempotency(options), &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) ListSignedWebhookConnectors(ctx context.Context, limit, offset int) ([]Connector, error) {
|
|
return c.listConnectors(ctx, "/v2/tenant/connectors/signed-webhooks", limit, offset)
|
|
}
|
|
|
|
func (c *Client) CreateSignedWebhookConnector(ctx context.Context, input ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
|
|
var out Connector
|
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/signed-webhooks", nil, input, idempotency(options), &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) RevokeSignedWebhookConnector(ctx context.Context, connectorID string) error {
|
|
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/signed-webhooks/"+url.PathEscape(connectorID))
|
|
}
|
|
|
|
func (c *Client) ListS3ObjectConnectors(ctx context.Context, limit, offset int) ([]Connector, error) {
|
|
return c.listConnectors(ctx, "/v2/tenant/connectors/s3-objects", limit, offset)
|
|
}
|
|
|
|
func (c *Client) CreateS3ObjectConnector(ctx context.Context, input S3ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
|
|
var out Connector
|
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects", nil, input, idempotency(options), &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) CommitS3Object(ctx context.Context, connectorID string, body M, options ...RequestOptions) (*EventReceipt, error) {
|
|
var out EventReceipt
|
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID)+"/commit", nil, body, idempotency(options), &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) RevokeS3ObjectConnector(ctx context.Context, connectorID string) error {
|
|
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID))
|
|
}
|
|
|
|
func (c *Client) ListRepositoryWebhookConnectors(ctx context.Context, limit, offset int) ([]Connector, error) {
|
|
return c.listConnectors(ctx, "/v2/tenant/connectors/repository-webhooks", limit, offset)
|
|
}
|
|
|
|
func (c *Client) CreateRepositoryWebhookConnector(ctx context.Context, input RepositoryConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
|
|
var out Connector
|
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/repository-webhooks", nil, input, idempotency(options), &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) RevokeRepositoryWebhookConnector(ctx context.Context, connectorID string) error {
|
|
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/repository-webhooks/"+url.PathEscape(connectorID))
|
|
}
|
|
|
|
func (c *Client) IngestSignedWebhookEvent(ctx context.Context, connectorID string, event EventInput, secret string) (*EventReceipt, error) {
|
|
if err := normalizeEventInput(&event); err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := json.Marshal(event)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
headers := SignedConnectorWebhookHeaders(secret, raw, 0)
|
|
var out EventReceipt
|
|
err = c.requestRaw(ctx, http.MethodPost, "/v2/connectors/signed-webhooks/"+url.PathEscape(connectorID)+"/events", nil, raw, headers, "", &out)
|
|
return &out, err
|
|
}
|
|
|
|
func normalizeEventInput(input *EventInput) error {
|
|
if input.SourceRef == "" {
|
|
return errors.New("source ref must not be blank")
|
|
}
|
|
if input.EventType == "" {
|
|
input.EventType = "inference"
|
|
}
|
|
if input.SourceKind == "" {
|
|
input.SourceKind = "sdk"
|
|
}
|
|
if input.OccurredAt == "" {
|
|
input.OccurredAt = time.Now().UTC().Format(time.RFC3339Nano)
|
|
} else if _, err := time.Parse(time.RFC3339, input.OccurredAt); err != nil {
|
|
if _, nanoErr := time.Parse(time.RFC3339Nano, input.OccurredAt); nanoErr != nil {
|
|
return errors.New("occurredAt must include a valid RFC3339 timezone offset")
|
|
}
|
|
}
|
|
if input.Payload == nil {
|
|
input.Payload = M{}
|
|
}
|
|
if input.Metadata == nil {
|
|
input.Metadata = M{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) IngestRepositoryWebhookEvent(ctx context.Context, connectorID string, rawBody []byte, headers map[string]string) (*EventReceipt, error) {
|
|
var out EventReceipt
|
|
err := c.requestRaw(ctx, http.MethodPost, "/v2/connectors/repository-webhooks/"+url.PathEscape(connectorID)+"/events", nil, rawBody, headers, "", &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) ListLocalVaultInstallations(ctx context.Context, limit, offset int) ([]LocalVaultInstallation, error) {
|
|
values := url.Values{}
|
|
setPaging(values, limit, offset)
|
|
var out []LocalVaultInstallation
|
|
err := c.requestJSON(ctx, http.MethodGet, "/v2/tenant/local-vault/installations", values, nil, "", &out)
|
|
return out, err
|
|
}
|
|
|
|
func (c *Client) CreateLocalVaultInstallation(ctx context.Context, input LocalVaultInstallationCreateInput, options ...RequestOptions) (*LocalVaultInstallation, error) {
|
|
var out LocalVaultInstallation
|
|
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/local-vault/installations", nil, input, idempotency(options), &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) RevokeLocalVaultInstallation(ctx context.Context, installationID string) error {
|
|
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/local-vault/installations/"+url.PathEscape(installationID))
|
|
}
|
|
|
|
func (c *Client) RelayLocalVaultEvent(ctx context.Context, installationID string, envelope M, payload M, envelopeHash, signatureHex, publicKeyHex string) (*EventReceipt, error) {
|
|
body := M{"envelope": envelope, "payload": payload}
|
|
headers := map[string]string{
|
|
"X-Attesto-Local-Vault-Envelope-Hash": envelopeHash,
|
|
"X-Attesto-Local-Vault-Signature": signatureHex,
|
|
"X-Attesto-Local-Vault-Public-Key": publicKeyHex,
|
|
}
|
|
var out EventReceipt
|
|
err := c.requestRaw(ctx, http.MethodPost, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/events", nil, mustJSON(body), headers, "", &out)
|
|
return &out, err
|
|
}
|
|
|
|
func (c *Client) SubmitLocalVaultWitnessReceipt(ctx context.Context, installationID string, receipt M, options ...RequestOptions) (M, error) {
|
|
return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"receipt": receipt}, idempotency(options))
|
|
}
|
|
|
|
func (c *Client) SubmitLocalVaultForkEvidence(ctx context.Context, installationID string, forkEvidence M, options ...RequestOptions) (M, error) {
|
|
return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"forkEvidence": forkEvidence}, idempotency(options))
|
|
}
|
|
|
|
func (c *Client) GetMarketplaceItem(ctx context.Context, slug string) (M, error) {
|
|
return c.getObject(ctx, "/v1/marketplace/items/"+url.PathEscape(slug), nil)
|
|
}
|
|
|
|
func (c *Client) SubmitMarketplaceAsset(ctx context.Context, input MarketplaceAssetSubmitInput, options ...RequestOptions) (M, error) {
|
|
return c.postObject(ctx, "/v1/marketplace/publisher/assets", M{
|
|
"manifest": input.Manifest,
|
|
"sourceRef": input.SourceRef,
|
|
"visibility": input.Visibility,
|
|
"pricingModel": input.PricingModel,
|
|
"priceCents": input.PriceCents,
|
|
}, idempotency(options))
|
|
}
|
|
|
|
func (c *Client) ListMarketplaceReviewAssets(ctx context.Context, state string) ([]M, error) {
|
|
values := url.Values{}
|
|
if state != "" {
|
|
values.Set("state", state)
|
|
}
|
|
var out []M
|
|
err := c.requestJSON(ctx, http.MethodGet, "/v1/platform/marketplace/assets", values, nil, "", &out)
|
|
return out, err
|
|
}
|
|
|
|
func (c *Client) ApproveMarketplaceAsset(ctx context.Context, slug string, reason string, options ...RequestOptions) (M, error) {
|
|
return c.postObject(ctx, "/v1/platform/marketplace/assets/"+url.PathEscape(slug)+"/approve", M{"reason": reason}, idempotency(options))
|
|
}
|
|
|
|
func (c *Client) getObject(ctx context.Context, path string, values url.Values) (M, error) {
|
|
var out M
|
|
err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out)
|
|
return out, err
|
|
}
|
|
|
|
func (c *Client) postObject(ctx context.Context, path string, body M, idempotencyKey string) (M, error) {
|
|
var out M
|
|
err := c.requestJSON(ctx, http.MethodPost, path, nil, body, idempotencyKey, &out)
|
|
return out, err
|
|
}
|
|
|
|
func (c *Client) getList(ctx context.Context, path string, limit, offset int) ([]M, error) {
|
|
values := url.Values{}
|
|
setPaging(values, limit, offset)
|
|
var out []M
|
|
err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out)
|
|
return out, err
|
|
}
|
|
|
|
func (c *Client) listConnectors(ctx context.Context, path string, limit, offset int) ([]Connector, error) {
|
|
values := url.Values{}
|
|
setPaging(values, limit, offset)
|
|
var out []Connector
|
|
err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out)
|
|
return out, err
|
|
}
|
|
|
|
func (c *Client) requestNoBody(ctx context.Context, method, path string) error {
|
|
return c.requestRaw(ctx, method, path, nil, nil, nil, "", nil)
|
|
}
|
|
|
|
func (c *Client) requestJSON(ctx context.Context, method, path string, values url.Values, body any, idempotencyKey string, out any) error {
|
|
var raw []byte
|
|
var err error
|
|
if body != nil {
|
|
raw, err = json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return c.requestRaw(ctx, method, path, values, raw, nil, idempotencyKey, out)
|
|
}
|
|
|
|
func (c *Client) requestRaw(ctx context.Context, method, path string, values url.Values, body []byte, extraHeaders map[string]string, idempotencyKey string, out any) error {
|
|
if values != nil && len(values) > 0 {
|
|
path += "?" + values.Encode()
|
|
}
|
|
var lastErr error
|
|
for attempt := 1; attempt <= c.maxRetries; attempt++ {
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.bearer)
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
req.Header.Set("X-Attesto-SDK", c.userAgent)
|
|
// [P1.9] protocol handshake: the backend answers 426 when it speaks a
|
|
// different protocol generation (ErrProtocolMismatch via APIError).
|
|
req.Header.Set("X-Attesto-Protocol", ProtocolHandshake)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if idempotencyKey != "" {
|
|
req.Header.Set("Idempotency-Key", idempotencyKey)
|
|
}
|
|
for key, value := range extraHeaders {
|
|
req.Header.Set(key, value)
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
lastErr = err
|
|
if attempt == c.maxRetries {
|
|
break
|
|
}
|
|
sleep(attempt)
|
|
continue
|
|
}
|
|
err = decodeResponse(resp, out)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
lastErr = err
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && !retryable(apiErr.StatusCode) {
|
|
return err
|
|
}
|
|
if attempt < c.maxRetries {
|
|
sleep(attempt)
|
|
}
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
type APIError struct {
|
|
StatusCode int
|
|
Message string
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
return fmt.Sprintf("attesto api error: status=%d message=%s", e.StatusCode, e.Message)
|
|
}
|
|
|
|
// IsProtocolMismatch reports whether err is a 426 protocol-handshake rejection:
|
|
// the backend speaks a different protocol generation than this SDK announced in
|
|
// X-Attesto-Protocol. Upgrade the SDK to a release that speaks it.
|
|
func IsProtocolMismatch(err error) bool {
|
|
var apiErr *APIError
|
|
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUpgradeRequired
|
|
}
|
|
|
|
func decodeResponse(resp *http.Response, out any) error {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNoContent {
|
|
return nil
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
if out == nil || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(raw, out)
|
|
}
|
|
message := http.StatusText(resp.StatusCode)
|
|
var parsed M
|
|
if json.Unmarshal(raw, &parsed) == nil {
|
|
if detail, ok := parsed["detail"].(string); ok && detail != "" {
|
|
message = detail
|
|
}
|
|
}
|
|
return &APIError{StatusCode: resp.StatusCode, Message: sanitizeMessage(message)}
|
|
}
|
|
|
|
func retryable(status int) bool {
|
|
switch status {
|
|
case http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func skipPreflight(options []RequestOptions) bool {
|
|
return len(options) > 0 && options[0].SkipPreflight
|
|
}
|
|
|
|
func idempotency(options []RequestOptions) string {
|
|
if len(options) > 0 && options[0].IdempotencyKey != "" {
|
|
return options[0].IdempotencyKey
|
|
}
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(raw[:])
|
|
}
|
|
|
|
func setPaging(values url.Values, limit, offset int) {
|
|
if limit > 0 {
|
|
values.Set("limit", fmt.Sprintf("%d", limit))
|
|
}
|
|
if offset > 0 {
|
|
values.Set("offset", fmt.Sprintf("%d", offset))
|
|
}
|
|
}
|
|
|
|
func sleep(attempt int) {
|
|
time.Sleep(time.Duration(100*attempt) * time.Millisecond)
|
|
}
|
|
|
|
func mustJSON(value any) []byte {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func sanitizeMessage(message string) string {
|
|
for _, marker := range []string{"sk_live_", "sk_test_", "pk_live_", "pk_test_", "npm_", "pypi-", "atto_live_", "atto_test_"} {
|
|
if strings.Contains(message, marker) {
|
|
return "redacted"
|
|
}
|
|
}
|
|
return message
|
|
}
|