diff --git a/README.md b/README.md index ccf3d20..e68e4d4 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,39 @@ func main() { } ``` +## Showing The Expiry Date + +`ActivateKey` and `FloatingCheckout` return `ExpirationDate`: when the license +expires, as an ISO 8601 string, or `nil` if it never expires. On +`FloatingCheckoutResponse` this is the license expiry; `ExpiresAt` is the +floating seat lease. + +```go +res, err := client.ActivateKey(keymint.ActivateKeyParams{ + ProductID: productId, + LicenseKey: licenseKey, + HostID: keymint.String(hostId), +}) +if err != nil { + // handle error +} + +if res.ExpirationDate == nil { + fmt.Println("License never expires") +} else if exp, err := time.Parse(time.RFC3339, *res.ExpirationDate); err == nil { + daysLeft := int(math.Ceil(time.Until(exp).Hours() / 24)) + if daysLeft < 0 { + daysLeft = 0 + } + fmt.Printf("Expires %s (%d days left)\n", exp.Format("2006-01-02"), daysLeft) +} +``` + +Store the last `ExpirationDate` your app received. An expired license fails +activation with the same generic error as an invalid key, so the stored date +lets your app tell the user the license expired on that date rather than +calling the key invalid. + ## Machine Identity Keymint provides utilities to uniquely identify machines for node-locking: diff --git a/Types.go b/Types.go index 873c390..e496065 100644 --- a/Types.go +++ b/Types.go @@ -140,6 +140,11 @@ type ActivateKeyResponse struct { Version map[string]interface{} `json:"version,omitempty"` // AllowedHosts is an optional list of authorized machine IDs. AllowedHosts []string `json:"allowedHosts,omitempty"` + // ExpirationDate is when the license expires, as an ISO 8601 string + // (e.g. "2027-03-01T00:00:00.000Z"). It is nil when the license never + // expires. An expired license fails activation with the generic + // invalid-key error, so store the last value received. + ExpirationDate *string `json:"expirationDate,omitempty"` } // DeactivateKeyParams represents parameters for the deactivateKey API endpoint. @@ -515,6 +520,9 @@ type FloatingCheckoutResponse struct { LicenseeName *string `json:"licenseeName,omitempty"` // LicenseeEmail is the optional email of the customer licensee. LicenseeEmail *string `json:"licenseeEmail,omitempty"` + // ExpirationDate is when the license expires, as an ISO 8601 string, or + // nil when it never expires. It is not the seat lease; see ExpiresAt. + ExpirationDate *string `json:"expirationDate,omitempty"` } // FloatingHeartbeatParams represents parameters for the floating license heartbeat API endpoint. diff --git a/contract_test.go b/contract_test.go index e13b405..1374e2c 100644 --- a/contract_test.go +++ b/contract_test.go @@ -167,3 +167,67 @@ func TestApiErrorsExposeNestedMessage(t *testing.T) { t.Fatalf("status not captured: %#v", apiErr.Status) } } + +// expirationCases covers the three shapes the API can send: a date, an +// explicit null (never expires), and an older server that omits the field. +var expirationCases = []struct { + name string + body string + want *string +}{ + {"date", `"expirationDate":"2027-03-01T00:00:00.000Z",`, String("2027-03-01T00:00:00.000Z")}, + {"null", `"expirationDate":null,`, nil}, + {"absent", ``, nil}, +} + +func checkExpiration(t *testing.T, got, want *string) { + t.Helper() + switch { + case want == nil && got != nil: + t.Fatalf("expected nil ExpirationDate, got %q", *got) + case want != nil && got == nil: + t.Fatalf("expected ExpirationDate %q, got nil", *want) + case want != nil && *got != *want: + t.Fatalf("expected ExpirationDate %q, got %q", *want, *got) + } +} + +func TestActivateKeyDeserializesExpirationDate(t *testing.T) { + for _, tc := range expirationCases { + t.Run(tc.name, func(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"message":"License valid",` + tc.body + `"metadata":{"hostId":"h1"}}`)) + }) + defer done() + + res, err := c.ActivateKey(ActivateKeyParams{ProductID: "p", LicenseKey: "k", HostID: String("h1")}) + if err != nil { + t.Fatalf("ActivateKey failed: %v", err) + } + checkExpiration(t, res.ExpirationDate, tc.want) + }) + } +} + +func TestFloatingCheckoutDeserializesExpirationDate(t *testing.T) { + for _, tc := range expirationCases { + t.Run(tc.name, func(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"message":"ok","sessionId":"s","sessionSecret":"sec","nextNonce":"n",` + + `"expiresAt":"2026-10-26T12:00:00.000Z","heartbeatInterval":60,` + tc.body + `"currentSessions":1,"maxSessions":5}`)) + }) + defer done() + + res, err := c.FloatingCheckout(FloatingCheckoutParams{ProductID: "p", LicenseKey: "k", HostID: "h"}) + if err != nil { + t.Fatalf("FloatingCheckout failed: %v", err) + } + if res.ExpiresAt != "2026-10-26T12:00:00.000Z" { + t.Fatalf("seat lease ExpiresAt changed: %q", res.ExpiresAt) + } + checkExpiration(t, res.ExpirationDate, tc.want) + }) + } +}