Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
30 changes: 30 additions & 0 deletions api/v3/handlers/governance/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package governance

import (
"context"

"github.com/openmeterio/openmeter/openmeter/governance"
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
)

type Handler interface {
QueryGovernanceAccess() QueryGovernanceAccessHandler
}

type handler struct {
resolveNamespace func(ctx context.Context) (string, error)
governanceService governance.Service
options []httptransport.HandlerOption
}

func New(
resolveNamespace func(ctx context.Context) (string, error),
governanceService governance.Service,
options ...httptransport.HandlerOption,
) Handler {
return &handler{
resolveNamespace: resolveNamespace,
governanceService: governanceService,
options: options,
}
}
109 changes: 109 additions & 0 deletions api/v3/handlers/governance/mapping.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package governance

import (
"github.com/oapi-codegen/nullable"
"github.com/samber/lo"

apiv3 "github.com/openmeterio/openmeter/api/v3"
customershandler "github.com/openmeterio/openmeter/api/v3/handlers/customers"
"github.com/openmeterio/openmeter/openmeter/governance"
)

// ToAPIGovernanceQueryResponse maps a domain QueryResult to the API response.
func ToAPIGovernanceQueryResponse(res governance.QueryResult, pageSize int) apiv3.GovernanceQueryResponse {
data := make([]apiv3.GovernanceQueryResult, 0, len(res.Customers))

for _, c := range res.Customers {
features := make(map[string]apiv3.GovernanceFeatureAccess, len(c.Features))
for key, fa := range c.Features {
features[key] = toAPIFeatureAccess(fa)
}

data = append(data, apiv3.GovernanceQueryResult{
Matched: c.Matched,
Customer: customershandler.ToAPIBillingCustomer(c.Customer),
Features: features,
UpdatedAt: c.UpdatedAt,
})
}

errs := make([]apiv3.GovernanceQueryError, 0, len(res.Errors))

for _, e := range res.Errors {
errs = append(errs, apiv3.GovernanceQueryError{
Customer: lo.ToPtr(e.CustomerKey),
Code: toAPIQueryErrorCode(e.Code),
Message: e.Message,
})
}

return apiv3.GovernanceQueryResponse{
Data: data,
Errors: errs,
Meta: toAPICursorMeta(res, pageSize),
}
}

func toAPIFeatureAccess(fa governance.FeatureAccess) apiv3.GovernanceFeatureAccess {
out := apiv3.GovernanceFeatureAccess{HasAccess: fa.HasAccess}

if fa.Reason != nil {
out.Reason = &apiv3.GovernanceFeatureAccessReason{
Code: toAPIReasonCode(fa.Reason.Code),
Message: fa.Reason.Message,
}
}

return out
}

func toAPIReasonCode(code governance.ReasonCode) apiv3.GovernanceFeatureAccessReasonCode {
switch code {
case governance.ReasonCodeUsageLimitReached:
return apiv3.GovernanceFeatureAccessReasonCodeUsageLimitReached
case governance.ReasonCodeFeatureUnavailable:
return apiv3.GovernanceFeatureAccessReasonCodeFeatureUnavailable
case governance.ReasonCodeFeatureNotFound:
return apiv3.GovernanceFeatureAccessReasonCodeFeatureNotFound
case governance.ReasonCodeNoCreditAvailable:
return apiv3.GovernanceFeatureAccessReasonCodeNoCreditAvailable
default:
return apiv3.GovernanceFeatureAccessReasonCodeUnknown
}
}

func toAPIQueryErrorCode(code governance.QueryErrorCode) apiv3.GovernanceQueryErrorCode {
switch code {
case governance.QueryErrorCustomerNotFound:
return apiv3.GovernanceQueryErrorCodeCustomerNotFound
default:
return apiv3.GovernanceQueryErrorCodeUnknown
}
}

// toAPICursorMeta builds cursor pagination metadata from the domain result.
func toAPICursorMeta(res governance.QueryResult, pageSize int) apiv3.CursorMeta {
meta := apiv3.CursorMeta{
Page: apiv3.CursorMetaPage{
Next: nullable.NewNullNullable[string](),
Previous: nullable.NewNullNullable[string](),
Size: float32(pageSize),
},
}

if res.First != nil {
meta.Page.First = lo.ToPtr(res.First.Encode())
if res.HasPrev {
meta.Page.Previous = nullable.NewNullableWithValue(res.First.Encode())
}
}

if res.Last != nil {
meta.Page.Last = lo.ToPtr(res.Last.Encode())
if res.HasNext {
meta.Page.Next = nullable.NewNullableWithValue(res.Last.Encode())
}
}

return meta
}
140 changes: 140 additions & 0 deletions api/v3/handlers/governance/query.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package governance

import (
"context"
"fmt"
"net/http"

apiv3 "github.com/openmeterio/openmeter/api/v3"
"github.com/openmeterio/openmeter/api/v3/apierrors"
"github.com/openmeterio/openmeter/openmeter/governance"
"github.com/openmeterio/openmeter/pkg/framework/commonhttp"
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
pagination "github.com/openmeterio/openmeter/pkg/pagination/v2"
)

const (
defaultPageSize = 100
maxPageSize = 100
)

type (
QueryGovernanceAccessParams = apiv3.QueryGovernanceAccessParams
QueryGovernanceAccessResponse = apiv3.GovernanceQueryResponse
QueryGovernanceAccessHandler = httptransport.HandlerWithArgs[governance.QueryAccessInput, QueryGovernanceAccessResponse, QueryGovernanceAccessParams]
)

func (h *handler) QueryGovernanceAccess() QueryGovernanceAccessHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, params QueryGovernanceAccessParams) (governance.QueryAccessInput, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return governance.QueryAccessInput{}, err
}

var body apiv3.GovernanceQueryRequest

if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil {
return governance.QueryAccessInput{}, err
}

input := governance.QueryAccessInput{
Namespace: ns,
CustomerKeys: body.Customer.Keys,
PageSize: defaultPageSize,
}

if body.Feature != nil {
input.FeatureKeys = body.Feature.Keys
}

if body.IncludeCredits != nil {
input.IncludeCredits = *body.IncludeCredits
}

if err := applyPaging(ctx, &input, params); err != nil {
return governance.QueryAccessInput{}, err
}

return input, nil
},
func(ctx context.Context, input governance.QueryAccessInput) (QueryGovernanceAccessResponse, error) {
res, err := h.governanceService.QueryAccess(ctx, input)
if err != nil {
return QueryGovernanceAccessResponse{}, err
}

return ToAPIGovernanceQueryResponse(res, input.PageSize), nil
},
commonhttp.JSONResponseEncoderWithStatus[QueryGovernanceAccessResponse](http.StatusOK),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("query-governance-access"),
httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
)...,
)
}

// applyPaging parses page[size]/page[after]/page[before] into the service input.
func applyPaging(ctx context.Context, input *governance.QueryAccessInput, params QueryGovernanceAccessParams) error {
if params.Page == nil {
return nil
}

if params.Page.Size != nil {
if *params.Page.Size < 1 || *params.Page.Size > maxPageSize {
return apierrors.NewBadRequestError(ctx,
fmt.Errorf("page[size] must be between 1 and %d", maxPageSize),
apierrors.InvalidParameters{{
Field: "page[size]",
Reason: fmt.Sprintf("must be between 1 and %d", maxPageSize),
Source: apierrors.InvalidParamSourceQuery,
}},
)
}

input.PageSize = *params.Page.Size
}

if params.Page.After != nil && params.Page.Before != nil {
return apierrors.NewBadRequestError(ctx,
fmt.Errorf("page[after] and page[before] are mutually exclusive"),
apierrors.InvalidParameters{{
Field: "page[after]",
Reason: "cannot be combined with page[before]",
Source: apierrors.InvalidParamSourceQuery,
}},
)
}

if params.Page.After != nil {
cursor, err := decodeCursorParam(ctx, "page[after]", *params.Page.After)
if err != nil {
return err
}
input.After = cursor
}

if params.Page.Before != nil {
cursor, err := decodeCursorParam(ctx, "page[before]", *params.Page.Before)
if err != nil {
return err
}
input.Before = cursor
}

return nil
}

func decodeCursorParam(ctx context.Context, field, raw string) (*pagination.Cursor, error) {
cursor, err := pagination.DecodeCursor(raw)
if err != nil {
return nil, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{{
Field: field,
Reason: err.Error(),
Source: apierrors.InvalidParamSourceQuery,
}})
}

return cursor, nil
}
2 changes: 1 addition & 1 deletion api/v3/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,5 +470,5 @@ func (s *Server) UpdateOrganizationDefaultTaxCodes(w http.ResponseWriter, r *htt
// Governance

func (s *Server) QueryGovernanceAccess(w http.ResponseWriter, r *http.Request, params api.QueryGovernanceAccessParams) {
unimplemented.QueryGovernanceAccess(w, r, params)
s.governanceHandler.QueryGovernanceAccess().With(params).ServeHTTP(w, r)
}
10 changes: 10 additions & 0 deletions api/v3/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
eventshandler "github.com/openmeterio/openmeter/api/v3/handlers/events"
featurecosthandler "github.com/openmeterio/openmeter/api/v3/handlers/featurecost"
featureshandler "github.com/openmeterio/openmeter/api/v3/handlers/features"
governancehandler "github.com/openmeterio/openmeter/api/v3/handlers/governance"
llmcosthandler "github.com/openmeterio/openmeter/api/v3/handlers/llmcost"
metershandler "github.com/openmeterio/openmeter/api/v3/handlers/meters"
planshandler "github.com/openmeterio/openmeter/api/v3/handlers/plans"
Expand All @@ -46,6 +47,7 @@ import (
"github.com/openmeterio/openmeter/openmeter/currencies"
"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/openmeter/entitlement"
"github.com/openmeterio/openmeter/openmeter/governance"
"github.com/openmeterio/openmeter/openmeter/ingest"
"github.com/openmeterio/openmeter/openmeter/ledger"
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
Expand Down Expand Up @@ -93,6 +95,7 @@ type Config struct {
AccountResolver ledger.AccountResolver
CustomerBalanceFacade *customerbalance.Facade
EntitlementService entitlement.Service
GovernanceService governance.Service
PlanService plan.Service
PlanAddonService planaddon.Service
PlanSubscriptionService plansubscription.PlanSubscriptionService
Expand Down Expand Up @@ -159,6 +162,10 @@ func (c *Config) Validate() error {
errs = append(errs, errors.New("entitlement service is required"))
}

if c.GovernanceService == nil {
errs = append(errs, errors.New("governance service is required"))
}

if c.PlanService == nil {
errs = append(errs, errors.New("plan service is required"))
}
Expand Down Expand Up @@ -238,6 +245,7 @@ type Server struct {
customersBillingHandler customersbillinghandler.Handler
customersCreditsHandler customerscreditshandler.Handler
customersEntitlementHandler customersentitlementhandler.Handler
governanceHandler governancehandler.Handler
metersHandler metershandler.Handler
subscriptionsHandler subscriptionshandler.Handler
subscriptionAddonsHandler subscriptionaddonshandler.Handler
Expand Down Expand Up @@ -318,6 +326,7 @@ func NewServer(config *Config) (*Server, error) {
}

featuresH := featureshandler.New(resolveNamespace, config.FeatureConnector, config.MeterService, config.LLMCostService, httptransport.WithErrorHandler(config.ErrorHandler))
governanceHandler := governancehandler.New(resolveNamespace, config.GovernanceService, httptransport.WithErrorHandler(config.ErrorHandler))

var llmcostH llmcosthandler.Handler
if config.LLMCostService != nil {
Expand Down Expand Up @@ -351,6 +360,7 @@ func NewServer(config *Config) (*Server, error) {
currenciesHandler: currenciesHandler,
featuresHandler: featuresH,
featureCostHandler: featureCostH,
governanceHandler: governanceHandler,
}, nil
}

Expand Down
31 changes: 31 additions & 0 deletions app/common/governance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package common

import (
"github.com/google/wire"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"

"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/openmeter/governance"
governanceservice "github.com/openmeterio/openmeter/openmeter/governance/service"
"github.com/openmeterio/openmeter/openmeter/registry"
)

var Governance = wire.NewSet(
NewGovernanceService,
)

func NewGovernanceService(
customer customer.Service,
entitlementRegistry *registry.Entitlement,
tracer trace.Tracer,
meter metric.Meter,
) (governance.Service, error) {
return governanceservice.New(governanceservice.Config{
Customer: customer,
Entitlement: entitlementRegistry.Entitlement,
Feature: entitlementRegistry.Feature,
Tracer: tracer,
Meter: meter,
})
}
1 change: 1 addition & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ func main() {
EntitlementBalanceConnector: app.EntitlementRegistry.MeteredEntitlement,
EntitlementConnector: app.EntitlementRegistry.Entitlement,
FeatureConnector: app.FeatureConnector,
GovernanceService: app.GovernanceService,
GrantConnector: app.EntitlementRegistry.Grant,
GrantRepo: app.EntitlementRegistry.GrantRepo,
IngestService: app.IngestService,
Expand Down
Loading
Loading