Skip to content

Commit 72ea64b

Browse files
refactor(governance): split package into root types + service subpackage
Follow the standard service-package layout: domain types in governance.go, the Service interface in service.go, and the implementation (Config, New, QueryAccess orchestration, entitlement→access mapping) under service/. Tests move alongside the implementation in service/. Wiring updated to call governanceservice.New; provider signature is unchanged so no wire regen.
1 parent 26bf336 commit 72ea64b

8 files changed

Lines changed: 352 additions & 345 deletions

File tree

app/common/governance.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/openmeterio/openmeter/openmeter/customer"
77
"github.com/openmeterio/openmeter/openmeter/governance"
8+
governanceservice "github.com/openmeterio/openmeter/openmeter/governance/service"
89
"github.com/openmeterio/openmeter/openmeter/registry"
910
)
1011

@@ -16,7 +17,7 @@ func NewGovernanceService(
1617
customerService customer.Service,
1718
entitlementRegistry *registry.Entitlement,
1819
) (governance.Service, error) {
19-
return governance.New(governance.Config{
20+
return governanceservice.New(governanceservice.Config{
2021
CustomerService: customerService,
2122
EntitlementService: entitlementRegistry.Entitlement,
2223
FeatureConnector: entitlementRegistry.Feature,

openmeter/governance/governance.go

Lines changed: 2 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
package governance
22

33
import (
4-
"context"
54
"errors"
65
"time"
76

87
"github.com/openmeterio/openmeter/openmeter/customer"
9-
"github.com/openmeterio/openmeter/openmeter/entitlement"
10-
"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
118
"github.com/openmeterio/openmeter/pkg/models"
129
pagination "github.com/openmeterio/openmeter/pkg/pagination/v2"
1310
)
@@ -65,6 +62,8 @@ type QueryError struct {
6562
Message string
6663
}
6764

65+
var _ models.Validator = (*QueryAccessInput)(nil)
66+
6867
// QueryAccessInput is the input for evaluating governance access.
6968
type QueryAccessInput struct {
7069
Namespace string
@@ -120,46 +119,3 @@ type QueryResult struct {
120119
First *pagination.Cursor
121120
Last *pagination.Cursor
122121
}
123-
124-
// Service evaluates feature access for customers by composing the customer, entitlement,
125-
// and feature services. It owns no persistence of its own.
126-
type Service interface {
127-
QueryAccess(ctx context.Context, input QueryAccessInput) (QueryResult, error)
128-
}
129-
130-
// Config holds the collaborating services for the governance Service.
131-
type Config struct {
132-
CustomerService customer.Service
133-
EntitlementService entitlement.Service
134-
FeatureConnector feature.FeatureConnector
135-
}
136-
137-
func (c Config) Validate() error {
138-
var errs []error
139-
140-
if c.CustomerService == nil {
141-
errs = append(errs, errors.New("customer service is required"))
142-
}
143-
144-
if c.EntitlementService == nil {
145-
errs = append(errs, errors.New("entitlement service is required"))
146-
}
147-
148-
if c.FeatureConnector == nil {
149-
errs = append(errs, errors.New("feature connector is required"))
150-
}
151-
152-
return errors.Join(errs...)
153-
}
154-
155-
func New(config Config) (Service, error) {
156-
if err := config.Validate(); err != nil {
157-
return nil, err
158-
}
159-
160-
return &service{
161-
customerService: config.CustomerService,
162-
entitlementService: config.EntitlementService,
163-
featureConnector: config.FeatureConnector,
164-
}, nil
165-
}

openmeter/governance/service.go

Lines changed: 5 additions & 255 deletions
Original file line numberDiff line numberDiff line change
@@ -1,259 +1,9 @@
11
package governance
22

3-
import (
4-
"context"
5-
"errors"
6-
"fmt"
7-
"sort"
8-
"time"
3+
import "context"
94

10-
"github.com/samber/lo"
11-
12-
"github.com/openmeterio/openmeter/openmeter/customer"
13-
"github.com/openmeterio/openmeter/openmeter/entitlement"
14-
"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
15-
"github.com/openmeterio/openmeter/pkg/clock"
16-
"github.com/openmeterio/openmeter/pkg/models"
17-
pagination "github.com/openmeterio/openmeter/pkg/pagination/v2"
18-
)
19-
20-
// featureFetchLimit caps the org-wide feature fetch used when no feature filter is given.
21-
// Acceptable for prototype scale; revisit if feature counts grow large.
22-
const featureFetchLimit = 10_000
23-
24-
type service struct {
25-
customerService customer.Service
26-
entitlementService entitlement.Service
27-
featureConnector feature.FeatureConnector
28-
}
29-
30-
var _ Service = (*service)(nil)
31-
32-
// resolvedCustomer groups the matched input keys for a single customer.
33-
type resolvedCustomer struct {
34-
Customer customer.Customer
35-
Matched []string
36-
}
37-
38-
func (s *service) QueryAccess(ctx context.Context, input QueryAccessInput) (QueryResult, error) {
39-
if err := input.Validate(); err != nil {
40-
return QueryResult{}, err
41-
}
42-
43-
// Resolve each input key to a customer; deduplicate by customer ID.
44-
customerMap := make(map[string]*resolvedCustomer)
45-
var queryErrors []QueryError
46-
47-
for _, key := range input.CustomerKeys {
48-
cus, err := s.customerService.GetCustomerByUsageAttribution(ctx, customer.GetCustomerByUsageAttributionInput{
49-
Namespace: input.Namespace,
50-
Key: key,
51-
})
52-
if err != nil {
53-
if models.IsGenericNotFoundError(err) {
54-
queryErrors = append(queryErrors, QueryError{
55-
CustomerKey: key,
56-
Code: QueryErrorCustomerNotFound,
57-
Message: "customer not found",
58-
})
59-
continue
60-
}
61-
return QueryResult{}, fmt.Errorf("resolve customer key %q: %w", key, err)
62-
}
63-
64-
if rc, ok := customerMap[cus.ID]; ok {
65-
rc.Matched = append(rc.Matched, key)
66-
} else {
67-
customerMap[cus.ID] = &resolvedCustomer{
68-
Customer: *cus,
69-
Matched: []string{key},
70-
}
71-
}
72-
}
73-
74-
// Sort by (CreatedAt, ID) for stable cursor pagination.
75-
customers := lo.Values(customerMap)
76-
sort.Slice(customers, func(i, j int) bool {
77-
ti := customers[i].Customer.CreatedAt
78-
tj := customers[j].Customer.CreatedAt
79-
if !ti.Equal(tj) {
80-
return ti.Before(tj)
81-
}
82-
return customers[i].Customer.ID < customers[j].Customer.ID
83-
})
84-
85-
customers, hasPrev, hasNext := paginate(customers, input)
86-
87-
var featureKeys []string
88-
if len(input.FeatureKeys) > 0 {
89-
featureKeys = input.FeatureKeys
90-
}
91-
92-
now := clock.Now()
93-
results := make([]CustomerAccess, 0, len(customers))
94-
for _, rc := range customers {
95-
access, err := s.entitlementService.GetAccess(ctx, input.Namespace, rc.Customer.ID)
96-
if err != nil {
97-
return QueryResult{}, fmt.Errorf("get access for customer %s: %w", rc.Customer.ID, err)
98-
}
99-
100-
featureAccess, err := s.buildFeatureAccess(ctx, input.Namespace, featureKeys, access)
101-
if err != nil {
102-
return QueryResult{}, fmt.Errorf("build feature access for customer %s: %w", rc.Customer.ID, err)
103-
}
104-
105-
results = append(results, CustomerAccess{
106-
Customer: rc.Customer,
107-
Matched: rc.Matched,
108-
Features: featureAccess,
109-
UpdatedAt: now,
110-
})
111-
}
112-
113-
out := QueryResult{
114-
Customers: results,
115-
Errors: queryErrors,
116-
HasPrev: hasPrev,
117-
HasNext: hasNext,
118-
}
119-
if len(customers) > 0 {
120-
out.First = lo.ToPtr(cursorFor(customers[0]))
121-
out.Last = lo.ToPtr(cursorFor(customers[len(customers)-1]))
122-
}
123-
124-
return out, nil
125-
}
126-
127-
// cursorFor builds the pagination cursor for a resolved customer. CreatedAt is truncated
128-
// to second precision to match the RFC3339 encoding used by cursor strings.
129-
func cursorFor(rc *resolvedCustomer) pagination.Cursor {
130-
return pagination.NewCursor(rc.Customer.CreatedAt.Truncate(time.Second), rc.Customer.ID)
131-
}
132-
133-
// paginate applies cursor pagination over the sorted customers and reports whether adjacent
134-
// pages exist. Exactly one of input.After / input.Before may be set (enforced by Validate).
135-
func paginate(customers []*resolvedCustomer, input QueryAccessInput) (page []*resolvedCustomer, hasPrev, hasNext bool) {
136-
if input.Before != nil {
137-
// Backward: take the last pageSize items strictly before the cursor.
138-
bc := *input.Before
139-
end := 0
140-
for i, rc := range customers {
141-
c := cursorFor(rc)
142-
if c.Time.After(bc.Time) || (c.Time.Equal(bc.Time) && c.ID >= bc.ID) {
143-
break
144-
}
145-
end = i + 1
146-
}
147-
candidates := customers[:end]
148-
hasPrev = len(candidates) > input.PageSize
149-
if hasPrev {
150-
candidates = candidates[len(candidates)-input.PageSize:]
151-
}
152-
// next is always set in backward mode: the before-cursor item itself is forward.
153-
return candidates, hasPrev, true
154-
}
155-
156-
// Forward (after cursor or first page).
157-
start := 0
158-
if input.After != nil {
159-
ac := *input.After
160-
start = len(customers) // beyond all items if cursor is past the end
161-
for i, rc := range customers {
162-
c := cursorFor(rc)
163-
if c.Time.After(ac.Time) || (c.Time.Equal(ac.Time) && c.ID > ac.ID) {
164-
start = i
165-
break
166-
}
167-
}
168-
}
169-
hasPrev = start > 0
170-
page = customers[start:]
171-
hasNext = len(page) > input.PageSize
172-
if hasNext {
173-
page = page[:input.PageSize]
174-
}
175-
return page, hasPrev, hasNext
176-
}
177-
178-
// buildFeatureAccess returns the feature access map for a single customer.
179-
// If featureKeys is non-empty, only those keys are evaluated.
180-
// If featureKeys is empty, all non-archived features in the namespace are returned;
181-
// features the customer has no entitlement for are marked feature-unavailable.
182-
func (s *service) buildFeatureAccess(ctx context.Context, ns string, featureKeys []string, access entitlement.Access) (map[string]FeatureAccess, error) {
183-
result := make(map[string]FeatureAccess)
184-
185-
if len(featureKeys) == 0 {
186-
orgFeatures, err := s.listAllOrgFeatures(ctx, ns)
187-
if err != nil {
188-
return nil, err
189-
}
190-
for _, f := range orgFeatures {
191-
if ev, ok := access.Entitlements[f.Key]; ok {
192-
result[f.Key] = mapEntitlementToAccess(ev.Value)
193-
} else {
194-
result[f.Key] = featureUnavailable(f.Key)
195-
}
196-
}
197-
return result, nil
198-
}
199-
200-
for _, key := range featureKeys {
201-
ev, ok := access.Entitlements[key]
202-
if !ok {
203-
fa, err := s.resolveAbsentFeature(ctx, ns, key)
204-
if err != nil {
205-
return nil, err
206-
}
207-
result[key] = fa
208-
continue
209-
}
210-
result[key] = mapEntitlementToAccess(ev.Value)
211-
}
212-
213-
return result, nil
214-
}
215-
216-
// listAllOrgFeatures fetches all non-archived features in the namespace in one shot.
217-
func (s *service) listAllOrgFeatures(ctx context.Context, ns string) ([]feature.Feature, error) {
218-
res, err := s.featureConnector.ListFeatures(ctx, feature.ListFeaturesParams{
219-
Namespace: ns,
220-
IncludeArchived: false,
221-
Limit: featureFetchLimit,
222-
})
223-
if err != nil {
224-
return nil, fmt.Errorf("list org features: %w", err)
225-
}
226-
return res.Items, nil
227-
}
228-
229-
// resolveAbsentFeature determines why a requested feature key is absent from GetAccess results:
230-
// either the feature doesn't exist in the org (feature-not-found) or the customer has no
231-
// entitlement for it (feature-unavailable).
232-
func (s *service) resolveAbsentFeature(ctx context.Context, ns, featureKey string) (FeatureAccess, error) {
233-
_, err := s.featureConnector.GetFeature(ctx, ns, featureKey, feature.IncludeArchivedFeatureFalse)
234-
if err != nil {
235-
var fne *feature.FeatureNotFoundError
236-
if errors.As(err, &fne) || models.IsGenericNotFoundError(err) {
237-
return FeatureAccess{
238-
HasAccess: false,
239-
Reason: &AccessReason{
240-
Code: ReasonFeatureNotFound,
241-
Message: fmt.Sprintf("feature %q not found", featureKey),
242-
},
243-
}, nil
244-
}
245-
return FeatureAccess{}, fmt.Errorf("get feature %q: %w", featureKey, err)
246-
}
247-
248-
return featureUnavailable(featureKey), nil
249-
}
250-
251-
func featureUnavailable(featureKey string) FeatureAccess {
252-
return FeatureAccess{
253-
HasAccess: false,
254-
Reason: &AccessReason{
255-
Code: ReasonFeatureUnavailable,
256-
Message: fmt.Sprintf("feature %q is not available for this customer", featureKey),
257-
},
258-
}
5+
// Service evaluates feature access for customers by composing the customer, entitlement,
6+
// and feature services. It owns no persistence of its own.
7+
type Service interface {
8+
QueryAccess(ctx context.Context, input QueryAccessInput) (QueryResult, error)
2599
}

0 commit comments

Comments
 (0)