Skip to content

Commit 2ea4031

Browse files
authored
fix: validate features before publishing plans/add-ons (#3661)
1 parent 0a3e63f commit 2ea4031

File tree

7 files changed

+224
-2
lines changed

7 files changed

+224
-2
lines changed

openmeter/productcatalog/addon.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package productcatalog
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"maps"
@@ -317,3 +318,9 @@ func AddonRateCardMatcherForAGivenPlanRateCard(planRateCard RateCard) func(addon
317318
return addonRateCard.Key() == planRateCard.Key()
318319
}
319320
}
321+
322+
func ValidateAddonWithFeatures(ctx context.Context, resolver NamespacedFeatureResolver) models.ValidatorFunc[Addon] {
323+
return func(a Addon) error {
324+
return ValidateRateCardsWithFeatures(ctx, resolver)(a.RateCards)
325+
}
326+
}

openmeter/productcatalog/addon/service/addon.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package service
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"sort"
78

@@ -510,9 +511,29 @@ func (s service) PublishAddon(ctx context.Context, params addon.PublishAddonInpu
510511
)
511512
}
512513

514+
pa := add.AsProductCatalogAddon()
515+
513516
// Run validations prior publishing add-on.
514-
if err = add.AsProductCatalogAddon().Publishable(); err != nil {
515-
return nil, err
517+
518+
var errs []error
519+
520+
if err = pa.Publishable(); err != nil {
521+
errs = append(errs, fmt.Errorf("invalid add-on [id=%s key=%s version=%d]: %w",
522+
add.ID, add.Key, add.Version, err),
523+
)
524+
}
525+
526+
// Validate plan with features
527+
resolver := productcatalog.NewNamespacedFeatureResolver(s.feature, params.Namespace)
528+
529+
if err = pa.ValidateWith(productcatalog.ValidateAddonWithFeatures(ctx, resolver)); err != nil {
530+
errs = append(errs, fmt.Errorf("invalid add-on [id=%s key=%s version=%d]: %w",
531+
add.ID, add.Key, add.Version, err),
532+
)
533+
}
534+
535+
if err = errors.Join(errs...); err != nil {
536+
return nil, models.NewGenericValidationError(err)
516537
}
517538

518539
// Find and archive add-on version with addon.AddonStatusActive if there is one. Only perform lookup if

openmeter/productcatalog/errors.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,36 @@ var ErrRateCardOnlyFlatPriceAllowed = models.NewValidationIssue(
101101
models.WithWarningSeverity(),
102102
)
103103

104+
const ErrCodeRateCardFeatureNotFound models.ErrorCode = "rate_card_feature_not_found"
105+
106+
var ErrRateCardFeatureNotFound = models.NewValidationIssue(
107+
ErrCodeRateCardFeatureNotFound,
108+
"feature not found",
109+
models.WithFieldString("featureKey"),
110+
models.WithCriticalSeverity(),
111+
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
112+
)
113+
114+
const ErrCodeRateCardFeatureArchived models.ErrorCode = "rate_card_feature_archived"
115+
116+
var ErrRateCardFeatureArchived = models.NewValidationIssue(
117+
ErrCodeRateCardFeatureArchived,
118+
"feature archived",
119+
models.WithFieldString("featureKey"),
120+
models.WithCriticalSeverity(),
121+
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
122+
)
123+
124+
const ErrCodeRateCardFeatureMismatch models.ErrorCode = "rate_card_feature_mismatch"
125+
126+
var ErrRateCardFeatureMismatch = models.NewValidationIssue(
127+
ErrCodeRateCardFeatureMismatch,
128+
"feature id and key must reference the same feature",
129+
models.WithFieldString("featureKey"),
130+
models.WithCriticalSeverity(),
131+
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
132+
)
133+
104134
const ErrCodeRateCardFeatureIDMismatch models.ErrorCode = "rate_card_feature_id_mismatch"
105135

106136
var ErrRateCardFeatureIDMismatch = models.NewValidationIssue(
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package productcatalog
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
9+
"github.com/openmeterio/openmeter/pkg/models"
10+
"github.com/openmeterio/openmeter/pkg/pagination"
11+
)
12+
13+
type NamespacedFeatureResolver interface {
14+
Resolve(ctx context.Context, id, key *string) (*feature.Feature, error)
15+
WithNamespace(namespace string) NamespacedFeatureResolver
16+
}
17+
18+
func NewNamespacedFeatureResolver(service feature.FeatureConnector, namespace string) NamespacedFeatureResolver {
19+
r := &namespacedFeatureResolver{
20+
service: service,
21+
namespace: namespace,
22+
}
23+
24+
return r
25+
}
26+
27+
var _ NamespacedFeatureResolver = (*namespacedFeatureResolver)(nil)
28+
29+
type namespacedFeatureResolver struct {
30+
service feature.FeatureConnector
31+
namespace string
32+
}
33+
34+
func (r *namespacedFeatureResolver) WithNamespace(namespace string) NamespacedFeatureResolver {
35+
return &namespacedFeatureResolver{
36+
service: r.service,
37+
namespace: namespace,
38+
}
39+
}
40+
41+
func (r *namespacedFeatureResolver) Resolve(ctx context.Context, id, key *string) (*feature.Feature, error) {
42+
if r.service == nil {
43+
return nil, errors.New("feature connector is not set")
44+
}
45+
46+
var featureIDOrKey []string
47+
48+
if id != nil && *id != "" {
49+
featureIDOrKey = append(featureIDOrKey, *id)
50+
}
51+
52+
if key != nil && *key != "" {
53+
featureIDOrKey = append(featureIDOrKey, *key)
54+
}
55+
56+
if len(featureIDOrKey) == 0 {
57+
return nil, errors.New("either feature id or key must be provided")
58+
}
59+
60+
features, err := r.service.ListFeatures(ctx, feature.ListFeaturesParams{
61+
IDsOrKeys: featureIDOrKey,
62+
Namespace: r.namespace,
63+
IncludeArchived: true,
64+
Page: pagination.Page{
65+
PageSize: 100,
66+
PageNumber: 1,
67+
},
68+
})
69+
if err != nil {
70+
return nil, fmt.Errorf("failed to fetch feature: %w", err)
71+
}
72+
73+
if len(features.Items) == 0 {
74+
return nil, models.NewGenericNotFoundError(errors.New("feature"))
75+
}
76+
77+
var f feature.Feature
78+
79+
m := make(map[string]struct{})
80+
81+
for _, f = range features.Items {
82+
m[f.ID] = struct{}{}
83+
}
84+
85+
if len(m) != 1 {
86+
return nil, models.NewGenericConflictError(fmt.Errorf("id and key reference %d different features", len(m)))
87+
}
88+
89+
return &f, nil
90+
}

openmeter/productcatalog/plan.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package productcatalog
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"time"
@@ -328,3 +329,24 @@ func (p PlanMeta) StatusAt(t time.Time) PlanStatus {
328329

329330
return PlanStatusInvalid
330331
}
332+
333+
func ValidatePlanWithFeatures(ctx context.Context, resolver NamespacedFeatureResolver) models.ValidatorFunc[Plan] {
334+
return func(p Plan) error {
335+
var errs []error
336+
337+
for _, phase := range p.Phases {
338+
phaseFieldSelector := models.NewFieldSelectorGroup(
339+
models.NewFieldSelector("phases").
340+
WithExpression(
341+
models.NewFieldAttrValue("key", phase.Key),
342+
),
343+
)
344+
345+
if err := ValidateRateCardsWithFeatures(ctx, resolver)(phase.RateCards); err != nil {
346+
errs = append(errs, models.ErrorWithFieldPrefix(phaseFieldSelector, err))
347+
}
348+
}
349+
350+
return errors.Join(errs...)
351+
}
352+
}

openmeter/productcatalog/plan/service/plan.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,15 @@ func (s service) PublishPlan(ctx context.Context, params plan.PublishPlanInput)
509509
)
510510
}
511511

512+
// Validate plan with features
513+
resolver := productcatalog.NewNamespacedFeatureResolver(s.feature, params.Namespace)
514+
515+
if err = pp.ValidateWith(productcatalog.ValidatePlanWithFeatures(ctx, resolver)); err != nil {
516+
errs = append(errs, fmt.Errorf("invalid plan [id=%s key=%s version=%d]: %w",
517+
p.ID, p.Key, p.Version, err),
518+
)
519+
}
520+
512521
// Check for incompatible add-ons assigned to this plan
513522

514523
if p.Addons == nil {

openmeter/productcatalog/ratecard.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package productcatalog
22

33
import (
4+
"context"
45
"encoding/json"
56
"errors"
67
"fmt"
78

89
"github.com/samber/lo"
910

1011
"github.com/openmeterio/openmeter/openmeter/entitlement"
12+
"github.com/openmeterio/openmeter/pkg/clock"
1113
"github.com/openmeterio/openmeter/pkg/datetime"
1214
"github.com/openmeterio/openmeter/pkg/models"
1315
)
@@ -841,3 +843,44 @@ var ValidateRateCardsHaveCompatibleDiscounts = models.ValidatorFunc[RateCardWith
841843

842844
return nil
843845
})
846+
847+
func ValidateRateCardsWithFeatures(ctx context.Context, resolver NamespacedFeatureResolver) func(cards RateCards) error {
848+
return func(rateCards RateCards) error {
849+
var errs []error
850+
851+
for _, rateCard := range rateCards {
852+
rc := rateCard.AsMeta()
853+
854+
rateCardFieldSelector := models.NewFieldSelectorGroup(
855+
models.NewFieldSelector("rateCards").
856+
WithExpression(
857+
models.NewFieldAttrValue("key", rateCard.Key()),
858+
),
859+
)
860+
861+
if rc.FeatureID == nil && rc.FeatureKey == nil {
862+
continue
863+
}
864+
865+
feat, err := resolver.Resolve(ctx, rc.FeatureID, rc.FeatureKey)
866+
if err != nil {
867+
switch {
868+
case models.IsGenericNotFoundError(err):
869+
errs = append(errs, models.ErrorWithFieldPrefix(rateCardFieldSelector, ErrRateCardFeatureNotFound))
870+
case models.IsGenericConflictError(err):
871+
errs = append(errs, models.ErrorWithFieldPrefix(rateCardFieldSelector, ErrRateCardFeatureMismatch))
872+
default:
873+
errs = append(errs, fmt.Errorf("failed to resolve feature for ratecard: %w", err))
874+
}
875+
876+
continue
877+
}
878+
879+
if feat.ArchivedAt != nil && clock.Now().UTC().After(feat.ArchivedAt.UTC()) {
880+
errs = append(errs, models.ErrorWithFieldPrefix(rateCardFieldSelector, ErrRateCardFeatureArchived))
881+
}
882+
}
883+
884+
return errors.Join(errs...)
885+
}
886+
}

0 commit comments

Comments
 (0)