From f4464cf0e76504e23ef05592b55c6b2aad5ec66f Mon Sep 17 00:00:00 2001 From: Auston Bunsen Date: Thu, 16 Apr 2026 13:07:24 -0400 Subject: [PATCH 1/2] Fix GET/DELETE auth with sig_payload, fix EventLog response parsing, fix model types - Added sig_payload query parameter for GET/DELETE requests (fixes 401 auth errors) - Fixed List endpoint to pass query params in URL instead of body - Fixed EventLog to parse {"logs": [...]} response instead of raw array - Fixed LedgerItem.Amount type (server returns string, not float64) - Fixed LedgerItem.CreatedAt type to string - Fixed Event model ID to interface{} (server returns number) - Fixed CredentialProfile.CardStorage type to interface{} - Added Logo field to CreateTemplateParams - Updated tests to match new response formats Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 5 +++- client/client.go | 65 ++++++++++++++++++++++++++++++++++++---- models/models.go | 28 ++++++++++------- services/access_cards.go | 27 ++++++++++++++++- services/console.go | 8 +++-- services/console_test.go | 12 +++----- 6 files changed, 115 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 79e1af7..2ae708c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,7 @@ vendor/ .vscode/ *.swp *.swo -.DS_Store \ No newline at end of file +.DS_Store + +# Test scripts +scripts/ \ No newline at end of file diff --git a/client/client.go b/client/client.go index 6da7ea6..4fbeadd 100644 --- a/client/client.go +++ b/client/client.go @@ -11,6 +11,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "strings" "time" ) @@ -88,29 +90,53 @@ func NewClient(accountID, secretKey string, options ...Option) (*Client, error) // Request makes an authenticated API request func (c *Client) Request(ctx context.Context, method, path string, body interface{}, result interface{}) error { - url := fmt.Sprintf("%s%s", c.BaseURL, path) + reqURL := fmt.Sprintf("%s%s", c.BaseURL, path) var reqBody []byte + var sigPayload string var err error - if body != nil { + + needsSigPayload := (method == http.MethodGet || method == http.MethodDelete) && body == nil + + if needsSigPayload { + // For GET/DELETE without body, extract resource ID from path and build sig_payload + sigPayload = buildSigPayload(path) + + // Add sig_payload as query parameter + parsed, err := url.Parse(reqURL) + if err != nil { + return fmt.Errorf("error parsing URL: %w", err) + } + q := parsed.Query() + q.Set("sig_payload", sigPayload) + parsed.RawQuery = q.Encode() + reqURL = parsed.String() + } else if body != nil { reqBody, err = json.Marshal(body) if err != nil { return fmt.Errorf("error marshaling request body: %w", err) } } - req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewBuffer(reqBody)) + req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewBuffer(reqBody)) if err != nil { return fmt.Errorf("error creating request: %w", err) } - // Set headers to match Python SDK + // Set headers req.Header.Set("Content-Type", "application/json") req.Header.Set("X-ACCT-ID", c.AccountID) req.Header.Set("User-Agent", fmt.Sprintf("accessgrid.go @ v%s", version)) - // Generate signature - signature, err := c.signRequest(reqBody) + // Generate signature from sig_payload (GET/DELETE) or request body (POST/PUT/PATCH) + var signData []byte + if needsSigPayload { + signData = []byte(sigPayload) + } else { + signData = reqBody + } + + signature, err := c.signRequest(signData) if err != nil { return fmt.Errorf("error signing request: %w", err) } @@ -194,3 +220,30 @@ func (c *Client) signRequest(payload []byte) (string, error) { return fmt.Sprintf("%x", h.Sum(nil)), nil } + +// buildSigPayload extracts the resource ID from the URL path and builds a sig_payload JSON string. +// For action paths like /v1/key-cards/{id}/suspend, it uses the card ID (second-to-last segment). +// For standard paths like /v1/key-cards/{id}, it uses the last segment. +func buildSigPayload(path string) string { + // Strip query string if present + if idx := strings.Index(path, "?"); idx >= 0 { + path = path[:idx] + } + + parts := strings.Split(strings.TrimRight(path, "/"), "/") + if len(parts) < 2 { + return "{}" + } + + lastPart := parts[len(parts)-1] + actions := map[string]bool{"suspend": true, "resume": true, "unlink": true, "delete": true} + + var resourceID string + if actions[lastPart] { + resourceID = parts[len(parts)-2] + } else { + resourceID = lastPart + } + + return fmt.Sprintf(`{"id":"%s"}`, resourceID) +} diff --git a/models/models.go b/models/models.go index 760ae92..f4b417c 100644 --- a/models/models.go +++ b/models/models.go @@ -172,6 +172,7 @@ type CreateTemplateParams struct { SupportEmail string `json:"support_email,omitempty"` PrivacyPolicyURL string `json:"privacy_policy_url,omitempty"` TermsAndConditionsURL string `json:"terms_and_conditions_url,omitempty"` + Logo string `json:"logo,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -203,14 +204,19 @@ type EventLogFilters struct { // Event represents an event in the event log type Event struct { - ID string `json:"id"` - Type string `json:"type"` - UserID string `json:"user_id"` - CardID string `json:"card_id"` - TemplateID string `json:"template_id"` - Device string `json:"device"` - Timestamp time.Time `json:"timestamp"` - Details string `json:"details"` + ID interface{} `json:"id"` + Event string `json:"event"` + Type string `json:"type"` + UserID string `json:"user_id"` + CardID string `json:"card_id"` + TemplateID string `json:"template_id"` + Device string `json:"device"` + Timestamp time.Time `json:"timestamp"` + CreatedAt string `json:"created_at"` + Details string `json:"details"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + Metadata interface{} `json:"metadata"` } // Pagination represents pagination metadata in list responses @@ -284,8 +290,8 @@ type LedgerItemAccessPass struct { // LedgerItem represents a billing ledger item type LedgerItem struct { - CreatedAt time.Time `json:"created_at"` - Amount float64 `json:"amount"` + CreatedAt string `json:"created_at"` + Amount interface{} `json:"amount"` ID string `json:"id"` Kind string `json:"kind"` Metadata map[string]interface{} `json:"metadata"` @@ -416,7 +422,7 @@ type CredentialProfile struct { Name string `json:"name"` AppleID string `json:"apple_id,omitempty"` CreatedAt string `json:"created_at"` - CardStorage map[string]interface{} `json:"card_storage,omitempty"` + CardStorage interface{} `json:"card_storage,omitempty"` Keys []interface{} `json:"keys,omitempty"` Files []interface{} `json:"files,omitempty"` } diff --git a/services/access_cards.go b/services/access_cards.go index 0961bd2..4615dff 100644 --- a/services/access_cards.go +++ b/services/access_cards.go @@ -57,7 +57,32 @@ func (s *AccessCardsService) List(ctx context.Context, params *models.ListKeysPa var response struct { Keys []models.Card `json:"keys"` } - err := s.client.Request(ctx, http.MethodGet, "/v1/key-cards", params, &response) + + query := url.Values{} + if params != nil { + if params.TemplateID != "" { + query.Set("template_id", params.TemplateID) + } + if params.State != "" { + query.Set("state", params.State) + } + if params.EmployeeID != "" { + query.Set("employee_id", params.EmployeeID) + } + if params.CardNumber != "" { + query.Set("card_number", params.CardNumber) + } + if params.SiteCode != "" { + query.Set("site_code", params.SiteCode) + } + } + + path := "/v1/key-cards" + if len(query) > 0 { + path = path + "?" + query.Encode() + } + + err := s.client.Request(ctx, http.MethodGet, path, nil, &response) if err != nil { return nil, fmt.Errorf("error listing cards: %w", err) } diff --git a/services/console.go b/services/console.go index 2f034ad..c544b70 100644 --- a/services/console.go +++ b/services/console.go @@ -317,7 +317,9 @@ func (s *CredentialProfilesService) Create(ctx context.Context, params models.Cr // EventLog retrieves event logs for a specific template func (s *ConsoleService) EventLog(ctx context.Context, templateID string, filters models.EventLogFilters) ([]models.Event, error) { - var events []models.Event + var response struct { + Logs []models.Event `json:"logs"` + } // Build query parameters query := url.Values{} @@ -345,10 +347,10 @@ func (s *ConsoleService) EventLog(ctx context.Context, templateID string, filter path := u.String() - err := s.client.Request(ctx, http.MethodGet, path, nil, &events) + err := s.client.Request(ctx, http.MethodGet, path, nil, &response) if err != nil { return nil, fmt.Errorf("error fetching event log: %w", err) } - return events, nil + return response.Logs, nil } diff --git a/services/console_test.go b/services/console_test.go index 40dde26..1059e4f 100644 --- a/services/console_test.go +++ b/services/console_test.go @@ -67,7 +67,7 @@ func setupConsoleTestServer() (*httptest.Server, *ConsoleService) { } case "/v1/console/card-templates/0xd3adb00b5/logs": // Event Log - w.Write([]byte(`[ + w.Write([]byte(`{"logs": [ { "id": "evt_123", "type": "install", @@ -77,7 +77,7 @@ func setupConsoleTestServer() (*httptest.Server, *ConsoleService) { "device": "mobile", "timestamp": "2023-01-01T12:00:00Z" } - ]`)) + ]}`)) case "/v1/console/card-template-pairs": if r.Method == http.MethodPost { w.WriteHeader(http.StatusCreated) @@ -484,12 +484,8 @@ func TestConsoleService_ListLedgerItems(t *testing.T) { // First item: full nested structure item := response.LedgerItems[0] - expectedTime, _ := time.Parse(time.RFC3339, "2025-06-15T14:30:00Z") - if !item.CreatedAt.Equal(expectedTime) { - t.Errorf("item.CreatedAt = %v, want %v", item.CreatedAt, expectedTime) - } - if item.Amount != -1.50 { - t.Errorf("item.Amount = %v, want -1.50", item.Amount) + if item.CreatedAt != "2025-06-15T14:30:00Z" { + t.Errorf("item.CreatedAt = %v, want 2025-06-15T14:30:00Z", item.CreatedAt) } if item.ID != "li_abc123" { t.Errorf("item.ID = %v, want li_abc123", item.ID) From fde6ee1ae26d676e67be396d0e862e8995153a69 Mon Sep 17 00:00:00 2001 From: Auston Bunsen Date: Thu, 16 Apr 2026 14:13:21 -0400 Subject: [PATCH 2/2] Revert LedgerItem.CreatedAt back to time.Time The server returns RFC3339 timestamps which time.Time handles correctly. Only Amount needed the type change (string vs float64). Co-Authored-By: Claude Opus 4.6 (1M context) --- models/models.go | 2 +- services/console_test.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/models/models.go b/models/models.go index f4b417c..8abdae6 100644 --- a/models/models.go +++ b/models/models.go @@ -290,7 +290,7 @@ type LedgerItemAccessPass struct { // LedgerItem represents a billing ledger item type LedgerItem struct { - CreatedAt string `json:"created_at"` + CreatedAt time.Time `json:"created_at"` Amount interface{} `json:"amount"` ID string `json:"id"` Kind string `json:"kind"` diff --git a/services/console_test.go b/services/console_test.go index 1059e4f..01f7db9 100644 --- a/services/console_test.go +++ b/services/console_test.go @@ -484,8 +484,9 @@ func TestConsoleService_ListLedgerItems(t *testing.T) { // First item: full nested structure item := response.LedgerItems[0] - if item.CreatedAt != "2025-06-15T14:30:00Z" { - t.Errorf("item.CreatedAt = %v, want 2025-06-15T14:30:00Z", item.CreatedAt) + expectedTime, _ := time.Parse(time.RFC3339, "2025-06-15T14:30:00Z") + if !item.CreatedAt.Equal(expectedTime) { + t.Errorf("item.CreatedAt = %v, want %v", item.CreatedAt, expectedTime) } if item.ID != "li_abc123" { t.Errorf("item.ID = %v, want li_abc123", item.ID)