From 84e3db8076160852c8acf8ba2bafc7e67c2961c4 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 16 Oct 2026 03:26:09 +0200 Subject: [PATCH] feat(reccontract): add Capabilities, UnavailableError and CheckAvailable First step of the provider-neutral request contract. Capabilities declares the request values a provider can honor, and Unavailable lists services it cannot recommend through its API. CheckAvailable returns an UnavailableError (errors.Is ErrUnavailable) for a listed service, so adapters can fail before any cloud call. Closes #389 Co-Authored-By: Claude Opus 5.5 --- pkg/reccontract/capabilities.go | 22 ++++++++++ pkg/reccontract/errors.go | 39 +++++++++++++++++ pkg/reccontract/errors_test.go | 75 +++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 pkg/reccontract/capabilities.go create mode 100644 pkg/reccontract/errors.go create mode 100644 pkg/reccontract/errors_test.go 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") + } +}