Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions pkg/reccontract/capabilities.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions pkg/reccontract/errors.go
Original file line number Diff line number Diff line change
@@ -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}
}
75 changes: 75 additions & 0 deletions pkg/reccontract/errors_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading