diff --git a/pkg/reccontract/capabilities.go b/pkg/reccontract/capabilities.go new file mode 100644 index 0000000..d8828dc --- /dev/null +++ b/pkg/reccontract/capabilities.go @@ -0,0 +1,22 @@ +// Package reccontract defines the recommendation request contract shared by +// providers. Capabilities declares which request values a provider can honor; +// an empty slice means the field is unsupported and any non-empty request for +// it fails loud. A service listed in Unavailable cannot be recommended via the +// provider's API, and requests for it fail with UnavailableError before any +// cloud call. +package reccontract + +import "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + +// Capabilities declares the request values a provider can honor. +type Capabilities struct { + Provider common.ProviderType + Terms []string // "1yr","3yr" + PaymentOptions []string // canonical payment tokens + Lookbacks []string // "7d","30d","60d"; empty when the provider has no lookback input + DefaultLookback string // filled into an empty request lookback; empty when the provider has none + AccountScopes []string // "linked","payer" + OfferingClasses []string // "standard","convertible" + SavingsPlanTypes bool // IncludeSPTypes/ExcludeSPTypes are meaningful + Unavailable map[common.ServiceType]string // service -> reason it cannot be recommended via API +} diff --git a/pkg/reccontract/errors.go b/pkg/reccontract/errors.go new file mode 100644 index 0000000..02236be --- /dev/null +++ b/pkg/reccontract/errors.go @@ -0,0 +1,39 @@ +package reccontract + +import ( + "errors" + "fmt" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" +) + +// ErrUnavailable matches any UnavailableError via errors.Is. +var ErrUnavailable = errors.New("service unavailable for recommendations") + +// UnavailableError reports a service that cannot be recommended via the provider's API. +type UnavailableError struct { + Provider common.ProviderType + Service common.ServiceType + Reason string +} + +func (e *UnavailableError) Error() string { + return fmt.Sprintf("%s %s recommendations unavailable: %s", e.Provider, e.Service, e.Reason) +} + +// Is reports whether target is ErrUnavailable. +func (e *UnavailableError) Is(target error) bool { + return target == ErrUnavailable +} + +// CheckAvailable returns an UnavailableError when s is listed in c.Unavailable. +func CheckAvailable(c Capabilities, s common.ServiceType) error { + if s == "" { + return nil + } + reason, ok := c.Unavailable[s] + if !ok { + return nil + } + return &UnavailableError{Provider: c.Provider, Service: s, Reason: reason} +} diff --git a/pkg/reccontract/errors_test.go b/pkg/reccontract/errors_test.go new file mode 100644 index 0000000..21c1464 --- /dev/null +++ b/pkg/reccontract/errors_test.go @@ -0,0 +1,75 @@ +package reccontract + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" +) + +func TestCheckAvailable(t *testing.T) { + t.Parallel() + const reason = "no recommendations API" + listed := common.ServiceType("svc-listed") + caps := Capabilities{ + Provider: common.ProviderType("prov"), + // The "" entry proves the empty-service guard, not a missing key, returns nil. + Unavailable: map[common.ServiceType]string{listed: reason, "": reason}, + } + tests := []struct { + name string + caps Capabilities + service common.ServiceType + wantErr bool + }{ + {"empty service", caps, "", false}, + {"listed service", caps, listed, true}, + {"unlisted service", caps, "svc-other", false}, + {"nil map", Capabilities{Provider: "prov"}, listed, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := CheckAvailable(tt.caps, tt.service) + if !tt.wantErr { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("errors.Is(ErrUnavailable) = false for %v", err) + } + var ue *UnavailableError + if !errors.As(err, &ue) { + t.Fatalf("errors.As *UnavailableError failed for %v", err) + } + if ue.Reason != reason || ue.Provider != "prov" || ue.Service != listed { + t.Fatalf("unexpected fields: %+v", ue) + } + }) + } +} + +func TestUnavailableErrorMessage(t *testing.T) { + t.Parallel() + err := &UnavailableError{Provider: "prov", Service: "svc", Reason: "because"} + for _, want := range []string{"prov", "svc", "because"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("Error() = %q, missing %q", err.Error(), want) + } + } +} + +func TestUnavailableErrorIsWrapped(t *testing.T) { + t.Parallel() + wrapped := fmt.Errorf("fetch: %w", &UnavailableError{Provider: "prov", Service: "svc", Reason: "r"}) + if !errors.Is(wrapped, ErrUnavailable) { + t.Fatal("errors.Is through %w wrapping = false") + } + if errors.Is(errors.New("other"), ErrUnavailable) { + t.Fatal("unrelated error matched ErrUnavailable") + } +}