diff --git a/Types.go b/Types.go index a788e29..6487e67 100644 --- a/Types.go +++ b/Types.go @@ -1,6 +1,9 @@ package keymint -import "fmt" +import ( + "encoding/json" + "fmt" +) // NewCustomer represents the structure for creating a new customer when creating a license key. type NewCustomer struct { @@ -88,8 +91,37 @@ type ApiError struct { Code int `json:"code"` // Status is the optional HTTP status code. Status *int `json:"status,omitempty"` - // ErrorDetail holds the nested error envelope when present. + // ErrorDetail holds the nested error envelope when `error` is an object. ErrorDetail *ApiErrorDetails `json:"error,omitempty"` + // ErrorText holds `error` when the API sends it as a plain string + // (e.g. "Method not allowed") instead of an object. + ErrorText string `json:"-"` +} + +// UnmarshalJSON accepts the `error` field as either a string or an object, +// since both shapes appear in Keymint API error responses. +func (e *ApiError) UnmarshalJSON(data []byte) error { + type apiErrorFields ApiError + var raw struct { + *apiErrorFields + Error json.RawMessage `json:"error,omitempty"` + } + raw.apiErrorFields = (*apiErrorFields)(e) + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw.Error) == 0 || string(raw.Error) == "null" { + return nil + } + if raw.Error[0] == '"' { + return json.Unmarshal(raw.Error, &e.ErrorText) + } + var detail ApiErrorDetails + if err := json.Unmarshal(raw.Error, &detail); err != nil { + return err + } + e.ErrorDetail = &detail + return nil } // Error implements the error interface for ApiError. diff --git a/contract_test.go b/contract_test.go index 92be84c..d2eb9b0 100644 --- a/contract_test.go +++ b/contract_test.go @@ -168,6 +168,64 @@ func TestApiErrorsExposeNestedMessage(t *testing.T) { } } +func TestApiErrorUnmarshalsErrorFieldShapes(t *testing.T) { + cases := []struct { + name string + body string + wantText string + wantDetail string + }{ + {"string", `{"message":"Method not allowed","code":1,"error":"Method not allowed"}`, "Method not allowed", ""}, + {"object", `{"message":"Too many requests","code":1,"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Too many requests"}}`, "", "RATE_LIMIT_EXCEEDED"}, + {"null", `{"message":"Invalid license key","code":1,"error":null}`, "", ""}, + {"absent", `{"message":"Invalid license key","code":1}`, "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var apiErr ApiError + if err := json.Unmarshal([]byte(tc.body), &apiErr); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if apiErr.Message == "" || apiErr.Code != 1 { + t.Fatalf("top-level fields not decoded: %#v", apiErr) + } + if apiErr.ErrorText != tc.wantText { + t.Fatalf("expected ErrorText %q, got %q", tc.wantText, apiErr.ErrorText) + } + switch { + case tc.wantDetail == "" && apiErr.ErrorDetail != nil: + t.Fatalf("expected nil ErrorDetail, got %#v", apiErr.ErrorDetail) + case tc.wantDetail != "" && (apiErr.ErrorDetail == nil || apiErr.ErrorDetail.Code == nil || *apiErr.ErrorDetail.Code != tc.wantDetail): + t.Fatalf("expected ErrorDetail code %q, got %#v", tc.wantDetail, apiErr.ErrorDetail) + } + }) + } +} + +func TestApiErrorsAcceptStringErrorField(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"This API key is not allowed from this IP address","message":"This API key is not allowed from this IP address","code":1}`)) + }) + defer done() + + _, err := c.ActivateKey(ActivateKeyParams{ProductID: "p", LicenseKey: "k"}) + apiErr, ok := err.(*ApiError) + if !ok { + t.Fatalf("expected *ApiError, got %T", err) + } + if apiErr.Code != 1 || apiErr.Message != "This API key is not allowed from this IP address" { + t.Fatalf("string error body not decoded: %#v", apiErr) + } + if apiErr.ErrorText != apiErr.Message || apiErr.ErrorDetail != nil { + t.Fatalf("unexpected error field: %#v", apiErr) + } + if apiErr.Status == nil || *apiErr.Status != http.StatusForbidden { + 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 {