Skip to content

Commit beab7d4

Browse files
committed
refactor: reject unit config in v1 handler/service instead of the response mapper
1 parent 2fc9af2 commit beab7d4

25 files changed

Lines changed: 320 additions & 82 deletions

File tree

e2e/v1readsurface_unitconfig_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,37 @@ func TestV1ReadSurfaceExcludesUnitConfig(t *testing.T) {
147147
assert.Equal(t, 1, resp.JSON200.TotalCount, "TotalCount must reflect the filtered set, not the raw count")
148148
})
149149

150+
// and:
151+
// - v1 mutations on the unit_config plan are rejected BEFORE any side effect. The guard runs
152+
// pre-commit in the service (not in the response mapper), so a 400 means nothing committed —
153+
// the plan is left exactly as it was. This is the core OM-409 follow-up: no commit-then-400.
154+
runRequired(t, "v1 plan mutations reject the unit_config plan with no side effect", func(t *testing.T) {
155+
// archive: the guard runs before the active-status check, so an active plan is still rejected.
156+
archiveResp, err := v1.ArchivePlanWithResponse(t.Context(), ucPlan.ID)
157+
require.NoError(t, err)
158+
require.Equal(t, http.StatusBadRequest, archiveResp.StatusCode(), "body: %s", string(archiveResp.Body))
159+
assert.Contains(t, string(archiveResp.Body), unitConfigNotRepresentableCode, "archive 400 must carry the typed unit_config code")
160+
161+
// publish: the guard runs before the publishable-status check.
162+
publishResp, err := v1.PublishPlanWithResponse(t.Context(), ucPlan.ID)
163+
require.NoError(t, err)
164+
require.Equal(t, http.StatusBadRequest, publishResp.StatusCode(), "body: %s", string(publishResp.Body))
165+
assert.Contains(t, string(publishResp.Body), unitConfigNotRepresentableCode, "publish 400 must carry the typed unit_config code")
166+
167+
// next: the guard runs before the next draft version is created.
168+
nextResp, err := v1.NextPlanWithResponse(t.Context(), ucPlan.ID)
169+
require.NoError(t, err)
170+
require.Equal(t, http.StatusBadRequest, nextResp.StatusCode(), "body: %s", string(nextResp.Body))
171+
assert.Contains(t, string(nextResp.Body), unitConfigNotRepresentableCode, "next 400 must carry the typed unit_config code")
172+
173+
// no side effect: after three rejected mutations the plan is unchanged — still the single
174+
// active version it was published as (a commit-then-400 mapper would have left it archived).
175+
after, err := c.Plans.Get(t.Context(), ucPlan.ID)
176+
c.requireStatus(http.StatusOK, err)
177+
require.NotNil(t, after)
178+
assert.Equal(t, v3sdk.PlanStatusActive, after.Status, "the plan must remain active — no mutation may have committed")
179+
})
180+
150181
// and:
151182
// - a v1 subscription created from the unit_config plan BY KEY still succeeds (read ≠ subscribe; the
152183
// server rates it correctly per OM-399, only the read surfaces are restricted).
@@ -185,4 +216,16 @@ func TestV1ReadSurfaceExcludesUnitConfig(t *testing.T) {
185216
require.NotNil(t, resp.ApplicationproblemJSON400)
186217
assert.Contains(t, string(resp.Body), unitConfigNotRepresentableCode, "the 400 must carry the typed unit_config code")
187218
})
219+
220+
// and:
221+
// - v1 add-on read surfaces of that subscription are rejected too (the sub-addon list serializes
222+
// the subscription's items, so it must not silently strip). Guarded on the subscription's spec,
223+
// so it rejects even with no add-ons attached.
224+
runRequired(t, "v1 subscription add-on LIST rejects the unit_config subscription", func(t *testing.T) {
225+
require.NotEmpty(t, subscriptionID)
226+
resp, err := v1.ListSubscriptionAddonsWithResponse(t.Context(), subscriptionID)
227+
require.NoError(t, err)
228+
require.Equal(t, http.StatusBadRequest, resp.StatusCode(), "body: %s", string(resp.Body))
229+
assert.Contains(t, string(resp.Body), unitConfigNotRepresentableCode, "the 400 must carry the typed unit_config code")
230+
})
188231
}

openmeter/productcatalog/addon.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@ func (a Addon) ValidateWith(validators ...models.ValidatorFunc[Addon]) error {
184184
return models.Validate(a, validators...)
185185
}
186186

187+
func (a Addon) HasUnitConfig() bool {
188+
return a.RateCards.HasUnitConfig()
189+
}
190+
187191
// ValidationErrors returns a list of possible validation errors for the add-on.
188192
// It returns nil if the add-on has no validation issues.
189193
func (a Addon) ValidationErrors() (models.ValidationIssues, error) {

openmeter/productcatalog/addon/httpdriver/addon.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ func (h *handler) UpdateAddon() UpdateAddonHandler {
187187

188188
req.IgnoreNonCriticalIssues = true
189189

190+
req.RejectUnitConfig = true
191+
190192
return req, nil
191193
},
192194
func(ctx context.Context, request UpdateAddonRequest) (UpdateAddonResponse, error) {
@@ -289,6 +291,10 @@ func (h *handler) GetAddon() GetAddonHandler {
289291
return GetAddonResponse{}, fmt.Errorf("failed to get add-on [namespace=%s key=%s id=%s]: %w", request.Namespace, request.Key, request.ID, err)
290292
}
291293

294+
if a.AsProductCatalogAddon().HasUnitConfig() {
295+
return GetAddonResponse{}, productcatalog.ErrUnitConfigNotRepresentable
296+
}
297+
292298
return FromAddon(*a)
293299
},
294300
commonhttp.JSONResponseEncoderWithStatus[GetAddonResponse](http.StatusOK),
@@ -324,6 +330,7 @@ func (h *handler) PublishAddon() PublishAddonHandler {
324330
EffectivePeriod: productcatalog.EffectivePeriod{
325331
EffectiveFrom: lo.ToPtr(clock.Now()),
326332
},
333+
RejectUnitConfig: true,
327334
}
328335

329336
return req, nil
@@ -367,6 +374,8 @@ func (h *handler) ArchiveAddon() ArchiveAddonHandler {
367374
ID: addonID,
368375
},
369376
EffectiveTo: clock.Now(),
377+
// The v1 API cannot represent unit_config; reject archiving such an add-on.
378+
RejectUnitConfig: true,
370379
}
371380

372381
return req, nil

openmeter/productcatalog/addon/service.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ type inputOptions struct {
148148
// ignoreNonCriticalIssues makes Validate() return errors with critical severity or higher.
149149
// This allows creating resource with expected validation issues.
150150
IgnoreNonCriticalIssues bool
151+
152+
// RejectUnitConfig makes mutation validation reject an add-on that carries a unit_config
153+
// conversion on any rate card. The v1 API cannot represent unit_config, and v1 update
154+
// rewrites rate cards from a body that has no such field, so proceeding would silently
155+
// drop the conversion. v1 handlers set this; v3 leaves it false.
156+
RejectUnitConfig bool
151157
}
152158

153159
var _ models.Validator = (*CreateAddonInput)(nil)
@@ -353,6 +359,10 @@ type PublishAddonInput struct {
353359

354360
// AddonEffectivePeriod
355361
productcatalog.EffectivePeriod
362+
363+
// RejectUnitConfig rejects the operation when the target add-on carries a unit_config
364+
// conversion. The v1 API cannot represent it, so v1 handlers set this; v3 leaves it false.
365+
RejectUnitConfig bool
356366
}
357367

358368
func (i PublishAddonInput) Validate() error {
@@ -401,6 +411,10 @@ type ArchiveAddonInput struct {
401411

402412
// EffectiveFrom defines the time from the Addon is going to be unpublished.
403413
EffectiveTo time.Time `json:"effectiveTo,omitempty"`
414+
415+
// RejectUnitConfig rejects the operation when the target add-on carries a unit_config
416+
// conversion. The v1 API cannot represent it, so v1 handlers set this; v3 leaves it false.
417+
RejectUnitConfig bool
404418
}
405419

406420
func (i ArchiveAddonInput) Validate() error {

openmeter/productcatalog/addon/service/addon.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,10 @@ func (s service) UpdateAddon(ctx context.Context, params addon.UpdateAddonInput)
342342
return nil, fmt.Errorf("failed to get add-on: %w", err)
343343
}
344344

345+
if params.RejectUnitConfig && add.AsProductCatalogAddon().HasUnitConfig() {
346+
return nil, productcatalog.ErrUnitConfigNotRepresentable
347+
}
348+
345349
// Run validations prior updating add-on.
346350
if err = add.AsProductCatalogAddon().ValidateWith(
347351
productcatalog.ValidateAddonWithStatus(productcatalog.AddonStatusDraft),
@@ -404,6 +408,10 @@ func (s service) PublishAddon(ctx context.Context, params addon.PublishAddonInpu
404408
)
405409
}
406410

411+
if params.RejectUnitConfig && add.AsProductCatalogAddon().HasUnitConfig() {
412+
return nil, productcatalog.ErrUnitConfigNotRepresentable
413+
}
414+
407415
pa := add.AsProductCatalogAddon()
408416

409417
// Run validations prior publishing add-on.
@@ -523,6 +531,10 @@ func (s service) ArchiveAddon(ctx context.Context, params addon.ArchiveAddonInpu
523531
)
524532
}
525533

534+
if params.RejectUnitConfig && add.AsProductCatalogAddon().HasUnitConfig() {
535+
return nil, productcatalog.ErrUnitConfigNotRepresentable
536+
}
537+
526538
// Run validations prior archiving add-on.
527539
if err = add.AsProductCatalogAddon().ValidateWith(
528540
productcatalog.ValidateAddonWithStatus(productcatalog.AddonStatusActive),

openmeter/productcatalog/http/mapping.go

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,6 @@ func FromRateCard(r productcatalog.RateCard) (api.RateCard, error) {
2222

2323
meta := r.AsMeta()
2424

25-
// The v1 rate card types have no unit_config field. This mapper is the single serialization choke
26-
// point for every v1 surface that emits a rate card, so it fails closed here rather than silently
27-
// dropping the conversion and misrepresenting billing: reject the object, available via the v3 API.
28-
// List surfaces exclude unit_config objects at the query layer so they never reach this mapper;
29-
// subscription items are serialized without this mapper, so subscription GET guards them via Spec.HasUnitConfig.
30-
if meta.UnitConfig != nil {
31-
return resp, productcatalog.ErrUnitConfigNotRepresentable
32-
}
33-
3425
var featureKey *string
3526
if meta.FeatureKey != nil {
3627
featureKey = meta.FeatureKey

openmeter/productcatalog/http/mapping_test.go

Lines changed: 0 additions & 59 deletions
This file was deleted.

openmeter/productcatalog/plan.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ func (p Plan) ValidateWith(validators ...models.ValidatorFunc[Plan]) error {
6363
return models.Validate(p, validators...)
6464
}
6565

66+
// HasUnitConfig reports whether any rate card in any phase carries a unit_config
67+
// conversion. The v1 API cannot represent unit_config, so v1 read and mutation
68+
// surfaces use this to reject such plans instead of silently stripping the field.
69+
func (p Plan) HasUnitConfig() bool {
70+
return lo.SomeBy(p.Phases, func(ph Phase) bool {
71+
return ph.RateCards.HasUnitConfig()
72+
})
73+
}
74+
6675
func ValidatePlanMeta() models.ValidatorFunc[Plan] {
6776
return func(p Plan) error {
6877
return p.PlanMeta.Validate()

openmeter/productcatalog/plan/httpdriver/plan.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ func (h *handler) UpdatePlan() UpdatePlanHandler {
183183
}
184184

185185
req.IgnoreNonCriticalIssues = true
186+
req.RejectUnitConfig = true
186187

187188
return req, nil
188189
},
@@ -331,6 +332,7 @@ func (h *handler) PublishPlan() PublishPlanHandler {
331332
EffectivePeriod: productcatalog.EffectivePeriod{
332333
EffectiveFrom: lo.ToPtr(clock.Now()),
333334
},
335+
RejectUnitConfig: true,
334336
}
335337

336338
return req, nil
@@ -373,7 +375,8 @@ func (h *handler) ArchivePlan() ArchivePlanHandler {
373375
Namespace: ns,
374376
ID: planID,
375377
},
376-
EffectiveTo: clock.Now(),
378+
EffectiveTo: clock.Now(),
379+
RejectUnitConfig: true,
377380
}
378381

379382
return req, nil
@@ -419,8 +422,9 @@ func (h *handler) NextPlan() NextPlanHandler {
419422
Namespace: ns,
420423
ID: idOrKey.ID,
421424
},
422-
Key: idOrKey.Key,
423-
Version: 0,
425+
Key: idOrKey.Key,
426+
Version: 0,
427+
RejectUnitConfig: true,
424428
}
425429

426430
return req, nil

openmeter/productcatalog/plan/service.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ type inputOptions struct {
151151
// ignoreNonCriticalIssues makes Validate() return errors with critical severity or higher.
152152
// This allows creating resource with expected validation issues.
153153
IgnoreNonCriticalIssues bool
154+
155+
// RejectUnitConfig makes mutation validation reject a plan that carries a unit_config conversion on any rate card.
156+
RejectUnitConfig bool
154157
}
155158

156159
var _ models.Validator = (*CreatePlanInput)(nil)
@@ -322,6 +325,10 @@ func (i UpdatePlanInput) Validate() error {
322325
}
323326

324327
func (i UpdatePlanInput) ValidateWithPlan(p productcatalog.Plan) error {
328+
if i.RejectUnitConfig && p.HasUnitConfig() {
329+
return productcatalog.ErrUnitConfigNotRepresentable
330+
}
331+
325332
var errs []error
326333

327334
if i.Name != nil {
@@ -423,6 +430,10 @@ type PublishPlanInput struct {
423430

424431
// EffectivePeriod
425432
productcatalog.EffectivePeriod
433+
434+
// RejectUnitConfig rejects the operation when the target plan carries a unit_config
435+
// conversion. The v1 API cannot represent it, so v1 handlers set this; v3 leaves it false.
436+
RejectUnitConfig bool
426437
}
427438

428439
func (i PublishPlanInput) Validate() error {
@@ -471,6 +482,10 @@ type ArchivePlanInput struct {
471482

472483
// EffectiveFrom defines the time from the Plan is going to be unpublished.
473484
EffectiveTo time.Time `json:"effectiveTo,omitempty"`
485+
486+
// RejectUnitConfig rejects the operation when the target plan carries a unit_config
487+
// conversion. The v1 API cannot represent it, so v1 handlers set this; v3 leaves it false.
488+
RejectUnitConfig bool
474489
}
475490

476491
func (i ArchivePlanInput) Validate() error {
@@ -506,6 +521,10 @@ type NextPlanInput struct {
506521
// Version is the version of the Plan.
507522
// If not set the latest version is assumed.
508523
Version int `json:"version,omitempty"`
524+
525+
// RejectUnitConfig rejects the operation when the target plan carries a unit_config
526+
// conversion. The v1 API cannot represent it, so v1 handlers set this; v3 leaves it false.
527+
RejectUnitConfig bool
509528
}
510529

511530
func (i NextPlanInput) Validate() error {

0 commit comments

Comments
 (0)