From e06ec91516998b1422e4ca87f073726ecf161b18 Mon Sep 17 00:00:00 2001 From: TalLevAmi Date: Sun, 27 Sep 2026 06:55:45 +0000 Subject: [PATCH] Send `expires_at` as a unix timestamp in archive and download URLs `CreateArchiveParams.ExpiresAt` and `PrivateDownloadURLParams.ExpiresAt` are `*time.Time`. `api.StructToParams` uses `json.Marshal`, which writes RFC 3339 text (`2027-01-15T08:00:00Z`). The API expects unix seconds, so it read only the year and rejected every request with "Stale request". Add `MarshalJSON` to both structs to write `expires_at` as unix seconds. The field type does not change, so callers are not broken. Co-Authored-By: Claude Opus 5.5 --- api/uploader/archive.go | 28 ++++++++++++++++++++++++++++ api/uploader/archive_test.go | 20 ++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/api/uploader/archive.go b/api/uploader/archive.go index 47f5672..4cbf9a3 100644 --- a/api/uploader/archive.go +++ b/api/uploader/archive.go @@ -2,6 +2,7 @@ package uploader import ( "context" + "encoding/json" "net/url" "time" @@ -59,6 +60,15 @@ type CreateArchiveParams struct { UseOriginalFilename *bool `json:"use_original_filename,omitempty"` } +// MarshalJSON sends ExpiresAt as a unix timestamp, as the API expects. +func (p CreateArchiveParams) MarshalJSON() ([]byte, error) { + type params CreateArchiveParams + return json.Marshal(struct { + params + ExpiresAt *int64 `json:"expires_at,omitempty"` + }{params(p), unixTime(p.ExpiresAt)}) +} + // CreateArchive creates a new archive in the server and returns information in JSON format. func (u *API) CreateArchive(ctx context.Context, params CreateArchiveParams) (*CreateArchiveResult, error) { res := &CreateArchiveResult{} @@ -179,6 +189,24 @@ type PrivateDownloadURLParams struct { ResourceType api.AssetType `json:"-"` } +// MarshalJSON sends ExpiresAt as a unix timestamp, as the API expects. +func (p PrivateDownloadURLParams) MarshalJSON() ([]byte, error) { + type params PrivateDownloadURLParams + return json.Marshal(struct { + params + ExpiresAt *int64 `json:"expires_at,omitempty"` + }{params(p), unixTime(p.ExpiresAt)}) +} + +// unixTime returns t as unix seconds, or nil when t is nil. +func unixTime(t *time.Time) *int64 { + if t == nil { + return nil + } + unix := t.Unix() + return &unix +} + // PrivateDownloadURL returns a URL that when invoked downloads the asset. func (u *API) PrivateDownloadURL(params PrivateDownloadURLParams) (string, error) { queryParams, err := api.StructToParams(params) diff --git a/api/uploader/archive_test.go b/api/uploader/archive_test.go index 5692c08..747477e 100644 --- a/api/uploader/archive_test.go +++ b/api/uploader/archive_test.go @@ -4,6 +4,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/cloudinary/cloudinary-go/v2/api" "github.com/cloudinary/cloudinary-go/v2/api/uploader" @@ -100,3 +101,22 @@ func TestUploader_PrivateDownloadURL(t *testing.T) { assert.Contains(t, privateDownloadURL, "signature") assert.Contains(t, privateDownloadURL, "timestamp") } + +func TestUploader_ExpiresAtIsUnixTimestamp(t *testing.T) { + expiresAt := time.Unix(1800000000, 0).UTC() + + archiveURL, err := uploadAPI.DownloadZipURL(uploader.CreateArchiveParams{ + PublicIDs: []string{cldtest.PublicID}, + ExpiresAt: &expiresAt, + }) + assert.NoError(t, err) + assert.Contains(t, archiveURL, "expires_at=1800000000") + + privateDownloadURL, err := uploadAPI.PrivateDownloadURL(uploader.PrivateDownloadURLParams{ + PublicID: cldtest.PublicID, + Format: "png", + ExpiresAt: &expiresAt, + }) + assert.NoError(t, err) + assert.Contains(t, privateDownloadURL, "expires_at=1800000000") +}