Leon4gr45 commited on
Commit
1f10f31
·
verified ·
1 Parent(s): 04f1444

Upload folder using huggingface_hub (part 4)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. api/v3/handlers/customers/credits/list_transactions.go +157 -0
  2. api/v3/handlers/customers/credits/list_transactions_test.go +117 -0
  3. api/v3/handlers/customers/credits/void_grant.go +55 -0
  4. api/v3/handlers/customers/credits/void_grant_test.go +71 -0
  5. api/v3/handlers/customers/delete.go +55 -0
  6. api/v3/handlers/customers/entitlementaccess/handler.go +34 -0
  7. api/v3/handlers/customers/entitlementaccess/list.go +97 -0
  8. api/v3/handlers/customers/entitlementaccess/mapping.go +46 -0
  9. api/v3/handlers/customers/get.go +53 -0
  10. api/v3/handlers/customers/handler.go +34 -0
  11. api/v3/handlers/customers/list.go +154 -0
  12. api/v3/handlers/customers/upsert.go +106 -0
  13. api/v3/handlers/events/convert.go +164 -0
  14. api/v3/handlers/events/handler.go +35 -0
  15. api/v3/handlers/events/ingest.go +106 -0
  16. api/v3/handlers/events/list.go +220 -0
  17. api/v3/handlers/events/list_test.go +110 -0
  18. api/v3/handlers/featurecost/convert.go +69 -0
  19. api/v3/handlers/featurecost/handler.go +42 -0
  20. api/v3/handlers/featurecost/query.go +97 -0
  21. api/v3/handlers/features/convert.go +352 -0
  22. api/v3/handlers/features/convert_test.go +574 -0
  23. api/v3/handlers/features/create.go +95 -0
  24. api/v3/handlers/features/create_test.go +60 -0
  25. api/v3/handlers/features/delete.go +49 -0
  26. api/v3/handlers/features/error_encoder.go +19 -0
  27. api/v3/handlers/features/get.go +66 -0
  28. api/v3/handlers/features/handler.go +42 -0
  29. api/v3/handlers/features/list.go +131 -0
  30. api/v3/handlers/features/update.go +66 -0
  31. api/v3/handlers/governance/handler.go +30 -0
  32. api/v3/handlers/governance/mapping.go +109 -0
  33. api/v3/handlers/governance/query.go +141 -0
  34. api/v3/handlers/llmcost/convert.go +216 -0
  35. api/v3/handlers/llmcost/convert_test.go +22 -0
  36. api/v3/handlers/llmcost/create_override.go +52 -0
  37. api/v3/handlers/llmcost/delete_override.go +48 -0
  38. api/v3/handlers/llmcost/get_price.go +48 -0
  39. api/v3/handlers/llmcost/handler.go +34 -0
  40. api/v3/handlers/llmcost/list_overrides.go +112 -0
  41. api/v3/handlers/llmcost/list_prices.go +138 -0
  42. api/v3/handlers/meters/convert.gen.go +79 -0
  43. api/v3/handlers/meters/convert.go +204 -0
  44. api/v3/handlers/meters/create.go +58 -0
  45. api/v3/handlers/meters/delete.go +56 -0
  46. api/v3/handlers/meters/dimensions.go +13 -0
  47. api/v3/handlers/meters/errors.go +22 -0
  48. api/v3/handlers/meters/get.go +51 -0
  49. api/v3/handlers/meters/handler.go +44 -0
  50. api/v3/handlers/meters/list.go +118 -0
api/v3/handlers/customers/credits/list_transactions.go ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customerscredits
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+
8
+ "github.com/oapi-codegen/nullable"
9
+ "github.com/samber/lo"
10
+
11
+ api "github.com/openmeterio/openmeter/api/v3"
12
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
13
+ "github.com/openmeterio/openmeter/openmeter/customer"
14
+ "github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
15
+ "github.com/openmeterio/openmeter/pkg/currencyx"
16
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
17
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
18
+ )
19
+
20
+ type (
21
+ ListCreditTransactionsRequest = customerbalance.ListCreditTransactionsInput
22
+ ListCreditTransactionsResponse = api.CreditTransactionPaginatedResponse
23
+ ListCreditTransactionsParams struct {
24
+ CustomerID api.ULID
25
+ Params api.ListCreditTransactionsParams
26
+ }
27
+ ListCreditTransactionsHandler httptransport.HandlerWithArgs[ListCreditTransactionsRequest, ListCreditTransactionsResponse, ListCreditTransactionsParams]
28
+ )
29
+
30
+ func (h *handler) ListCreditTransactions() ListCreditTransactionsHandler {
31
+ return httptransport.NewHandlerWithArgs(
32
+ func(ctx context.Context, r *http.Request, args ListCreditTransactionsParams) (ListCreditTransactionsRequest, error) {
33
+ ns, err := h.resolveNamespace(ctx)
34
+ if err != nil {
35
+ return ListCreditTransactionsRequest{}, err
36
+ }
37
+
38
+ size := 20
39
+ if args.Params.Page != nil {
40
+ size = lo.FromPtrOr(args.Params.Page.Size, 20)
41
+ }
42
+
43
+ if size < 1 {
44
+ err := fmt.Errorf("must be greater than 0")
45
+ return ListCreditTransactionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
46
+ {
47
+ Field: "page.size",
48
+ Reason: "must be greater than 0",
49
+ Source: apierrors.InvalidParamSourceQuery,
50
+ },
51
+ })
52
+ }
53
+
54
+ req := customerbalance.ListCreditTransactionsInput{
55
+ CustomerID: customer.CustomerID{
56
+ Namespace: ns,
57
+ ID: args.CustomerID,
58
+ },
59
+ Limit: size,
60
+ }
61
+
62
+ if args.Params.Page != nil {
63
+ if args.Params.Page.After != nil {
64
+ after, err := decodeBillingCreditTransactionCursor(*args.Params.Page.After, ns)
65
+ if err != nil {
66
+ return ListCreditTransactionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
67
+ {
68
+ Field: "page.after",
69
+ Reason: err.Error(),
70
+ Source: apierrors.InvalidParamSourceQuery,
71
+ },
72
+ })
73
+ }
74
+
75
+ req.After = after
76
+ }
77
+
78
+ if args.Params.Page.Before != nil {
79
+ before, err := decodeBillingCreditTransactionCursor(*args.Params.Page.Before, ns)
80
+ if err != nil {
81
+ return ListCreditTransactionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
82
+ {
83
+ Field: "page.before",
84
+ Reason: err.Error(),
85
+ Source: apierrors.InvalidParamSourceQuery,
86
+ },
87
+ })
88
+ }
89
+
90
+ req.Before = before
91
+ }
92
+ }
93
+
94
+ if args.Params.Filter != nil {
95
+ req.Type = fromAPIBillingCreditTransactionType(args.Params.Filter.Type)
96
+
97
+ if args.Params.Filter.Currency != nil {
98
+ currency := currencyx.Code(*args.Params.Filter.Currency)
99
+ req.Currency = &currency
100
+ }
101
+
102
+ featureFilter, err := fromAPICustomerCreditFeatureFilter(args.Params.Filter.FeatureKey)
103
+ if err != nil {
104
+ return ListCreditTransactionsRequest{}, newFeatureKeyFilterBadRequest(ctx, err)
105
+ }
106
+
107
+ req.FeatureFilter = featureFilter
108
+ }
109
+
110
+ return req, nil
111
+ },
112
+ func(ctx context.Context, request ListCreditTransactionsRequest) (ListCreditTransactionsResponse, error) {
113
+ result, err := h.balanceFacade.ListCreditTransactions(ctx, request)
114
+ if err != nil {
115
+ return ListCreditTransactionsResponse{}, fmt.Errorf("list credit transactions: %w", err)
116
+ }
117
+
118
+ // We intentionally expose opaque cursor tokens instead of URI links.
119
+ // This endpoint reuses the shared cursor metadata schema, but still emits
120
+ // opaque token values rather than fully qualified URLs.
121
+ meta := api.CursorMeta{
122
+ Page: api.CursorMetaPage{
123
+ Next: nullable.NewNullNullable[string](),
124
+ Previous: nullable.NewNullNullable[string](),
125
+ Size: float32(request.Limit),
126
+ },
127
+ }
128
+
129
+ if result.NextCursor != nil {
130
+ next, err := encodeBillingCreditTransactionCursor(*result.NextCursor)
131
+ if err != nil {
132
+ return ListCreditTransactionsResponse{}, fmt.Errorf("encode next cursor: %w", err)
133
+ }
134
+ meta.Page.Next = nullable.NewNullableWithValue(next)
135
+ }
136
+
137
+ if result.PreviousCursor != nil {
138
+ previous, err := encodeBillingCreditTransactionCursor(*result.PreviousCursor)
139
+ if err != nil {
140
+ return ListCreditTransactionsResponse{}, fmt.Errorf("encode previous cursor: %w", err)
141
+ }
142
+ meta.Page.Previous = nullable.NewNullableWithValue(previous)
143
+ }
144
+
145
+ return api.CreditTransactionPaginatedResponse{
146
+ Data: toAPIBillingCreditTransactions(result.Items),
147
+ Meta: meta,
148
+ }, nil
149
+ },
150
+ commonhttp.JSONResponseEncoderWithStatus[ListCreditTransactionsResponse](http.StatusOK),
151
+ httptransport.AppendOptions(
152
+ h.options,
153
+ httptransport.WithOperationName("list-credit-transactions"),
154
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
155
+ )...,
156
+ )
157
+ }
api/v3/handlers/customers/credits/list_transactions_test.go ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customerscredits
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/alpacahq/alpacadecimal"
8
+ "github.com/stretchr/testify/require"
9
+
10
+ api "github.com/openmeterio/openmeter/api/v3"
11
+ "github.com/openmeterio/openmeter/openmeter/ledger"
12
+ "github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
13
+ "github.com/openmeterio/openmeter/pkg/currencyx"
14
+ "github.com/openmeterio/openmeter/pkg/models"
15
+ )
16
+
17
+ func TestFromAPIBillingCreditTransactionType_Funded(t *testing.T) {
18
+ filter := api.BillingCreditTransactionTypeFunded
19
+
20
+ txType := fromAPIBillingCreditTransactionType(&filter)
21
+
22
+ require.NotNil(t, txType)
23
+ require.Equal(t, customerbalance.CreditTransactionTypeFunded, *txType)
24
+ }
25
+
26
+ func TestFromAPIBillingCreditTransactionType_Expired(t *testing.T) {
27
+ filter := api.BillingCreditTransactionTypeExpired
28
+
29
+ txType := fromAPIBillingCreditTransactionType(&filter)
30
+
31
+ require.NotNil(t, txType)
32
+ require.Equal(t, customerbalance.CreditTransactionTypeExpired, *txType)
33
+ }
34
+
35
+ func TestToAPIBillingCreditTransaction(t *testing.T) {
36
+ createdAt := time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)
37
+ bookedAt := createdAt.Add(time.Second)
38
+ description := "Welcome credits"
39
+
40
+ tx := toAPIBillingCreditTransaction(customerbalance.CreditTransaction{
41
+ ID: models.NamespacedID{
42
+ Namespace: "ns",
43
+ ID: "tx-1",
44
+ },
45
+ CreatedAt: createdAt,
46
+ BookedAt: bookedAt,
47
+ Type: customerbalance.CreditTransactionTypeConsumed,
48
+ Currency: currencyx.Code("USD"),
49
+ Amount: alpacadecimal.NewFromInt(-10),
50
+ Balance: customerbalance.CreditTransactionBalance{
51
+ Before: alpacadecimal.NewFromInt(52),
52
+ After: alpacadecimal.NewFromInt(42),
53
+ },
54
+ Name: "credit_transaction",
55
+ Description: &description,
56
+ Annotations: models.Annotations{
57
+ ledger.AnnotationChargeID: "charge-1",
58
+ },
59
+ })
60
+
61
+ require.Equal(t, api.ULID("tx-1"), tx.Id)
62
+ require.Equal(t, api.BillingCreditTransactionTypeConsumed, tx.Type)
63
+ require.Equal(t, api.BillingCurrencyCode("USD"), tx.Currency)
64
+ require.Equal(t, api.Numeric("-10"), tx.Amount)
65
+ require.Equal(t, api.Numeric("52"), tx.AvailableBalance.Before)
66
+ require.Equal(t, api.Numeric("42"), tx.AvailableBalance.After)
67
+ require.NotNil(t, tx.Description)
68
+ require.Equal(t, description, *tx.Description)
69
+ require.NotNil(t, tx.Labels)
70
+ require.Equal(t, "charge-1", (*tx.Labels)["charge_id"])
71
+ }
72
+
73
+ func TestToAPIBillingCreditTransaction_Expired(t *testing.T) {
74
+ tx := toAPIBillingCreditTransaction(customerbalance.CreditTransaction{
75
+ ID: models.NamespacedID{
76
+ Namespace: "ns",
77
+ ID: "tx-1",
78
+ },
79
+ CreatedAt: time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC),
80
+ BookedAt: time.Date(2026, 4, 11, 9, 0, 0, 0, time.UTC),
81
+ Type: customerbalance.CreditTransactionTypeExpired,
82
+ Currency: currencyx.Code("USD"),
83
+ Amount: alpacadecimal.NewFromInt(-4),
84
+ })
85
+
86
+ require.Equal(t, api.BillingCreditTransactionTypeExpired, tx.Type)
87
+ }
88
+
89
+ func TestCreditTransactionCursorConversion(t *testing.T) {
90
+ bookedAt := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC)
91
+ createdAt := bookedAt.Add(-time.Second)
92
+ namespace := "ns"
93
+
94
+ ledgerCursor := ledger.TransactionCursor{
95
+ BookedAt: bookedAt,
96
+ CreatedAt: createdAt,
97
+ ID: models.NamespacedID{
98
+ Namespace: namespace,
99
+ ID: "01J7JABCDXYZ1234567890ABCD",
100
+ },
101
+ }
102
+
103
+ encoded, err := encodeBillingCreditTransactionCursor(ledgerCursor)
104
+ require.NoError(t, err)
105
+
106
+ decoded, err := decodeBillingCreditTransactionCursor(encoded, namespace)
107
+ require.NoError(t, err)
108
+ require.Equal(t, bookedAt, decoded.BookedAt)
109
+ require.Equal(t, createdAt, decoded.CreatedAt)
110
+ require.Equal(t, namespace, decoded.ID.Namespace)
111
+ require.Equal(t, ledgerCursor.ID.ID, decoded.ID.ID)
112
+ }
113
+
114
+ func TestCreditTransactionCursorConversion_InvalidCursor(t *testing.T) {
115
+ _, err := decodeBillingCreditTransactionCursor("not-base64", "ns")
116
+ require.Error(t, err)
117
+ }
api/v3/handlers/customers/credits/void_grant.go ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customerscredits
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ api "github.com/openmeterio/openmeter/api/v3"
8
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
9
+ "github.com/openmeterio/openmeter/api/v3/request"
10
+ "github.com/openmeterio/openmeter/openmeter/billing/creditgrant"
11
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
12
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
13
+ )
14
+
15
+ type (
16
+ VoidCreditGrantRequest = creditgrant.VoidInput
17
+ VoidCreditGrantResponse = api.BillingCreditGrant
18
+ VoidCreditGrantParams struct {
19
+ CustomerID api.ULID
20
+ CreditGrantID api.ULID
21
+ }
22
+ VoidCreditGrantHandler = httptransport.HandlerWithArgs[VoidCreditGrantRequest, VoidCreditGrantResponse, VoidCreditGrantParams]
23
+ )
24
+
25
+ func (h *handler) VoidCreditGrant() VoidCreditGrantHandler {
26
+ return httptransport.NewHandlerWithArgs(
27
+ func(ctx context.Context, r *http.Request, args VoidCreditGrantParams) (VoidCreditGrantRequest, error) {
28
+ ns, err := h.resolveNamespace(ctx)
29
+ if err != nil {
30
+ return VoidCreditGrantRequest{}, err
31
+ }
32
+
33
+ var body api.VoidCreditGrantRequest
34
+ if err := request.ParseOptionalBody(r, &body); err != nil {
35
+ return VoidCreditGrantRequest{}, err
36
+ }
37
+
38
+ return fromAPIVoidCreditGrantRequest(ns, args.CustomerID, args.CreditGrantID, body)
39
+ },
40
+ func(ctx context.Context, request VoidCreditGrantRequest) (VoidCreditGrantResponse, error) {
41
+ grant, err := h.creditGrantService.Void(ctx, request)
42
+ if err != nil {
43
+ return VoidCreditGrantResponse{}, err
44
+ }
45
+
46
+ return toAPIBillingCreditGrant(grant)
47
+ },
48
+ commonhttp.JSONResponseEncoderWithStatus[VoidCreditGrantResponse](http.StatusOK),
49
+ httptransport.AppendOptions(
50
+ h.options,
51
+ httptransport.WithOperationName("void-credit-grant"),
52
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
53
+ )...,
54
+ )
55
+ }
api/v3/handlers/customers/credits/void_grant_test.go ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customerscredits
2
+
3
+ import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "testing"
7
+
8
+ "github.com/stretchr/testify/require"
9
+
10
+ api "github.com/openmeterio/openmeter/api/v3"
11
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
12
+ "github.com/openmeterio/openmeter/openmeter/billing/creditgrant"
13
+ "github.com/openmeterio/openmeter/pkg/models"
14
+ )
15
+
16
+ func TestConvertAPIVoidCreditGrantRequestDefaultsPaymentAdjustment(t *testing.T) {
17
+ req, err := fromAPIVoidCreditGrantRequest(
18
+ "ns",
19
+ "cust-1",
20
+ "grant-1",
21
+ api.VoidCreditGrantRequest{},
22
+ )
23
+
24
+ require.NoError(t, err)
25
+ require.Equal(t, "ns", req.Namespace)
26
+ require.Equal(t, "cust-1", req.CustomerID)
27
+ require.Equal(t, "grant-1", req.ChargeID)
28
+ require.Equal(t, creditgrant.VoidPaymentAdjustmentNone, req.PaymentAdjustment)
29
+ }
30
+
31
+ func TestConvertAPIVoidCreditGrantRequestAcceptsNonePaymentAdjustment(t *testing.T) {
32
+ adjustment := api.BillingCreditGrantVoidPaymentAdjustmentNone
33
+
34
+ req, err := fromAPIVoidCreditGrantRequest(
35
+ "ns",
36
+ "cust-1",
37
+ "grant-1",
38
+ api.VoidCreditGrantRequest{
39
+ PaymentAdjustment: &adjustment,
40
+ },
41
+ )
42
+
43
+ require.NoError(t, err)
44
+ require.Equal(t, creditgrant.VoidPaymentAdjustmentNone, req.PaymentAdjustment)
45
+ }
46
+
47
+ func TestConvertAPIVoidCreditGrantRequestRejectsUnsupportedPaymentAdjustment(t *testing.T) {
48
+ adjustment := api.BillingCreditGrantVoidPaymentAdjustment("external")
49
+
50
+ _, err := fromAPIVoidCreditGrantRequest(
51
+ "ns",
52
+ "cust-1",
53
+ "grant-1",
54
+ api.VoidCreditGrantRequest{
55
+ PaymentAdjustment: &adjustment,
56
+ },
57
+ )
58
+
59
+ require.Error(t, err)
60
+ issues, convErr := models.AsValidationIssues(err)
61
+ require.NoError(t, convErr)
62
+ require.Len(t, issues, 1)
63
+ require.ErrorContains(t, err, "unsupported credit grant void payment adjustment")
64
+
65
+ rec := httptest.NewRecorder()
66
+ req := httptest.NewRequest(http.MethodPost, "/api/v3/customers/cust-1/credits/grants/grant-1/void", nil)
67
+ handled := apierrors.GenericErrorEncoder()(t.Context(), err, rec, req)
68
+
69
+ require.True(t, handled)
70
+ require.Equal(t, http.StatusBadRequest, rec.Code)
71
+ }
api/v3/handlers/customers/delete.go ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customers
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
8
+ customer "github.com/openmeterio/openmeter/openmeter/customer"
9
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
10
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
11
+ )
12
+
13
+ type (
14
+ DeleteCustomerRequest struct {
15
+ Namespace string
16
+ CustomerID string
17
+ }
18
+ DeleteCustomerResponse = interface{}
19
+ DeleteCustomerParams = string
20
+ DeleteCustomerHandler httptransport.HandlerWithArgs[DeleteCustomerRequest, DeleteCustomerResponse, DeleteCustomerParams]
21
+ )
22
+
23
+ // DeleteCustomer returns a handler for deleting a customer.
24
+ func (h *handler) DeleteCustomer() DeleteCustomerHandler {
25
+ return httptransport.NewHandlerWithArgs(
26
+ func(ctx context.Context, r *http.Request, customerID DeleteCustomerParams) (DeleteCustomerRequest, error) {
27
+ ns, err := h.resolveNamespace(ctx)
28
+ if err != nil {
29
+ return DeleteCustomerRequest{}, err
30
+ }
31
+
32
+ return DeleteCustomerRequest{
33
+ Namespace: ns,
34
+ CustomerID: customerID,
35
+ }, nil
36
+ },
37
+ func(ctx context.Context, request DeleteCustomerRequest) (DeleteCustomerResponse, error) {
38
+ err := h.service.DeleteCustomer(ctx, customer.DeleteCustomerInput{
39
+ Namespace: request.Namespace,
40
+ ID: request.CustomerID,
41
+ })
42
+ if err != nil {
43
+ return nil, err
44
+ }
45
+
46
+ return nil, nil
47
+ },
48
+ commonhttp.EmptyResponseEncoder[DeleteCustomerResponse](http.StatusNoContent),
49
+ httptransport.AppendOptions(
50
+ h.options,
51
+ httptransport.WithOperationName("delete-customer"),
52
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
53
+ )...,
54
+ )
55
+ }
api/v3/handlers/customers/entitlementaccess/handler.go ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customersentitlement
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/customer"
7
+ "github.com/openmeterio/openmeter/openmeter/entitlement"
8
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
9
+ )
10
+
11
+ type Handler interface {
12
+ ListCustomerEntitlementAccess() ListCustomerEntitlementAccessHandler
13
+ }
14
+
15
+ type handler struct {
16
+ resolveNamespace func(ctx context.Context) (string, error)
17
+ customerService customer.Service
18
+ entitlementService entitlement.Service
19
+ options []httptransport.HandlerOption
20
+ }
21
+
22
+ func New(
23
+ resolveNamespace func(ctx context.Context) (string, error),
24
+ customerService customer.Service,
25
+ entitlementService entitlement.Service,
26
+ options ...httptransport.HandlerOption,
27
+ ) Handler {
28
+ return &handler{
29
+ resolveNamespace: resolveNamespace,
30
+ customerService: customerService,
31
+ entitlementService: entitlementService,
32
+ options: options,
33
+ }
34
+ }
api/v3/handlers/customers/entitlementaccess/list.go ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customersentitlement
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+ "sort"
8
+
9
+ api "github.com/openmeterio/openmeter/api/v3"
10
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
11
+ "github.com/openmeterio/openmeter/openmeter/customer"
12
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
13
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
14
+ )
15
+
16
+ type (
17
+ CustomerID = string
18
+ ListCustomerEntitlementAccessResponse = api.ListCustomerEntitlementAccessResponseData
19
+ ListCustomerEntitlementAccessHandler httptransport.HandlerWithArgs[ListCustomerEntitlementAccessRequest, ListCustomerEntitlementAccessResponse, CustomerID]
20
+ )
21
+
22
+ type ListCustomerEntitlementAccessRequest struct {
23
+ CustomerID customer.CustomerID
24
+ }
25
+
26
+ func (h *handler) ListCustomerEntitlementAccess() ListCustomerEntitlementAccessHandler {
27
+ return httptransport.NewHandlerWithArgs(
28
+ func(ctx context.Context, r *http.Request, customerID CustomerID) (ListCustomerEntitlementAccessRequest, error) {
29
+ ns, err := h.resolveNamespace(ctx)
30
+ if err != nil {
31
+ return ListCustomerEntitlementAccessRequest{}, err
32
+ }
33
+
34
+ req := ListCustomerEntitlementAccessRequest{
35
+ CustomerID: customer.CustomerID{
36
+ Namespace: ns,
37
+ ID: customerID,
38
+ },
39
+ }
40
+
41
+ return req, nil
42
+ },
43
+ func(ctx context.Context, request ListCustomerEntitlementAccessRequest) (ListCustomerEntitlementAccessResponse, error) {
44
+ // Get the customer
45
+ cus, err := h.customerService.GetCustomer(ctx, customer.GetCustomerInput{
46
+ CustomerID: &request.CustomerID,
47
+ })
48
+ if err != nil {
49
+ return ListCustomerEntitlementAccessResponse{}, err
50
+ }
51
+
52
+ if cus != nil && cus.IsDeleted() {
53
+ return ListCustomerEntitlementAccessResponse{},
54
+ apierrors.NewPreconditionFailedError(ctx,
55
+ fmt.Sprintf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID),
56
+ )
57
+ }
58
+
59
+ // Get the access
60
+ access, err := h.entitlementService.GetAccess(ctx, cus.Namespace, cus.ID)
61
+ if err != nil {
62
+ return ListCustomerEntitlementAccessResponse{}, err
63
+ }
64
+
65
+ // Convert the access to the API response
66
+ items := make([]api.BillingEntitlementAccessResult, 0, len(access.Entitlements))
67
+ for featureKey, entitlement := range access.Entitlements {
68
+ found, item, err := mapEntitlementValueToAPI(featureKey, entitlement.Value)
69
+ if err != nil {
70
+ return ListCustomerEntitlementAccessResponse{}, err
71
+ }
72
+
73
+ if !found {
74
+ continue
75
+ }
76
+
77
+ items = append(items, item)
78
+ }
79
+
80
+ // Sort the items by feature key
81
+ sort.Slice(items, func(i, j int) bool {
82
+ return items[i].FeatureKey < items[j].FeatureKey
83
+ })
84
+
85
+ // Return the response
86
+ return ListCustomerEntitlementAccessResponse{
87
+ Data: items,
88
+ }, nil
89
+ },
90
+ commonhttp.JSONResponseEncoderWithStatus[ListCustomerEntitlementAccessResponse](http.StatusOK),
91
+ httptransport.AppendOptions(
92
+ h.options,
93
+ httptransport.WithOperationName("list-customer-entitlement-access"),
94
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
95
+ )...,
96
+ )
97
+ }
api/v3/handlers/customers/entitlementaccess/mapping.go ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customersentitlement
2
+
3
+ import (
4
+ "errors"
5
+
6
+ api "github.com/openmeterio/openmeter/api/v3"
7
+ "github.com/openmeterio/openmeter/openmeter/entitlement"
8
+ booleanentitlement "github.com/openmeterio/openmeter/openmeter/entitlement/boolean"
9
+ meteredentitlement "github.com/openmeterio/openmeter/openmeter/entitlement/metered"
10
+ staticentitlement "github.com/openmeterio/openmeter/openmeter/entitlement/static"
11
+ )
12
+
13
+ // mapEntitlementValueToAPI maps an entitlement value to an API entitlement access result.
14
+ func mapEntitlementValueToAPI(featureKey string, entitlementValue entitlement.EntitlementValue) (bool, api.BillingEntitlementAccessResult, error) {
15
+ switch ent := entitlementValue.(type) {
16
+ case *meteredentitlement.MeteredEntitlementValue:
17
+ return true, api.BillingEntitlementAccessResult{
18
+ FeatureKey: featureKey,
19
+ Type: api.BillingEntitlementTypeMetered,
20
+ HasAccess: ent.HasAccess(),
21
+ }, nil
22
+ case *staticentitlement.StaticEntitlementValue:
23
+ accessResult := api.BillingEntitlementAccessResult{
24
+ FeatureKey: featureKey,
25
+ Type: api.BillingEntitlementTypeStatic,
26
+ HasAccess: ent.HasAccess(),
27
+ Config: &ent.Config,
28
+ }
29
+
30
+ return true, accessResult, nil
31
+ case *booleanentitlement.BooleanEntitlementValue:
32
+ return true, api.BillingEntitlementAccessResult{
33
+ FeatureKey: featureKey,
34
+ Type: api.BillingEntitlementTypeBoolean,
35
+ HasAccess: ent.HasAccess(),
36
+ }, nil
37
+
38
+ case *entitlement.NoAccessValue:
39
+ // FIXME(pmarton): do we need to handle no access value?
40
+ // According to comments this only happens when the entitlement is not active
41
+ // This is the reason why we return a bool and not an error
42
+ return false, api.BillingEntitlementAccessResult{}, nil
43
+ default:
44
+ return true, api.BillingEntitlementAccessResult{}, errors.New("unknown entitlement type")
45
+ }
46
+ }
api/v3/handlers/customers/get.go ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customers
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ api "github.com/openmeterio/openmeter/api/v3"
8
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
9
+ "github.com/openmeterio/openmeter/openmeter/customer"
10
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
11
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
12
+ )
13
+
14
+ type (
15
+ GetCustomerRequest = customer.GetCustomerInput
16
+ GetCustomerResponse = api.BillingCustomer
17
+ GetCustomerParams = string
18
+ GetCustomerHandler httptransport.HandlerWithArgs[GetCustomerRequest, GetCustomerResponse, GetCustomerParams]
19
+ )
20
+
21
+ // GetCustomer returns a handler for getting a customer.
22
+ func (h *handler) GetCustomer() GetCustomerHandler {
23
+ return httptransport.NewHandlerWithArgs(
24
+ func(ctx context.Context, r *http.Request, customerID GetCustomerParams) (GetCustomerRequest, error) {
25
+ ns, err := h.resolveNamespace(ctx)
26
+ if err != nil {
27
+ return GetCustomerRequest{}, err
28
+ }
29
+
30
+ return GetCustomerRequest{
31
+ CustomerID: &customer.CustomerID{
32
+ Namespace: ns,
33
+ ID: customerID,
34
+ },
35
+ }, nil
36
+ },
37
+ func(ctx context.Context, request GetCustomerRequest) (GetCustomerResponse, error) {
38
+ // Get the customer
39
+ cus, err := h.service.GetCustomer(ctx, request)
40
+ if err != nil {
41
+ return GetCustomerResponse{}, err
42
+ }
43
+
44
+ return ToAPIBillingCustomer(*cus), nil
45
+ },
46
+ commonhttp.JSONResponseEncoderWithStatus[GetCustomerResponse](http.StatusOK),
47
+ httptransport.AppendOptions(
48
+ h.options,
49
+ httptransport.WithOperationName("get-customer"),
50
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
51
+ )...,
52
+ )
53
+ }
api/v3/handlers/customers/handler.go ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customers
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/customer"
7
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
8
+ )
9
+
10
+ type Handler interface {
11
+ ListCustomers() ListCustomersHandler
12
+ CreateCustomer() CreateCustomerHandler
13
+ DeleteCustomer() DeleteCustomerHandler
14
+ GetCustomer() GetCustomerHandler
15
+ UpsertCustomer() UpsertCustomerHandler
16
+ }
17
+
18
+ type handler struct {
19
+ resolveNamespace func(ctx context.Context) (string, error)
20
+ service customer.Service
21
+ options []httptransport.HandlerOption
22
+ }
23
+
24
+ func New(
25
+ resolveNamespace func(ctx context.Context) (string, error),
26
+ service customer.Service,
27
+ options ...httptransport.HandlerOption,
28
+ ) Handler {
29
+ return &handler{
30
+ resolveNamespace: resolveNamespace,
31
+ service: service,
32
+ options: options,
33
+ }
34
+ }
api/v3/handlers/customers/list.go ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customers
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+
8
+ "github.com/samber/lo"
9
+
10
+ api "github.com/openmeterio/openmeter/api/v3"
11
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
12
+ "github.com/openmeterio/openmeter/api/v3/filters"
13
+ "github.com/openmeterio/openmeter/api/v3/request"
14
+ "github.com/openmeterio/openmeter/api/v3/response"
15
+ "github.com/openmeterio/openmeter/openmeter/customer"
16
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
17
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
18
+ "github.com/openmeterio/openmeter/pkg/pagination"
19
+ "github.com/openmeterio/openmeter/pkg/sortx"
20
+ )
21
+
22
+ type (
23
+ ListCustomersRequest = customer.ListCustomersInput
24
+ ListCustomersResponse = response.PagePaginationResponse[api.BillingCustomer]
25
+ ListCustomersParams = api.ListCustomersParams
26
+ ListCustomersHandler httptransport.HandlerWithArgs[ListCustomersRequest, ListCustomersResponse, ListCustomersParams]
27
+ )
28
+
29
+ func (h *handler) ListCustomers() ListCustomersHandler {
30
+ return httptransport.NewHandlerWithArgs(
31
+ func(ctx context.Context, r *http.Request, params ListCustomersParams) (ListCustomersRequest, error) {
32
+ ns, err := h.resolveNamespace(ctx)
33
+ if err != nil {
34
+ return ListCustomersRequest{}, err
35
+ }
36
+
37
+ page := pagination.NewPage(1, 20)
38
+ if params.Page != nil {
39
+ page = pagination.NewPage(
40
+ lo.FromPtrOr(params.Page.Number, 1),
41
+ lo.FromPtrOr(params.Page.Size, 20),
42
+ )
43
+ }
44
+
45
+ if err := page.Validate(); err != nil {
46
+ return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
47
+ apierrors.InvalidParameter{
48
+ Field: "page",
49
+ Reason: err.Error(),
50
+ Source: apierrors.InvalidParamSourceQuery,
51
+ },
52
+ })
53
+ }
54
+
55
+ var orderBy string
56
+ var order sortx.Order
57
+ if params.Sort != nil {
58
+ sort, err := request.ParseSortBy(*params.Sort)
59
+ if err != nil {
60
+ return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
61
+ apierrors.InvalidParameter{
62
+ Field: "sort",
63
+ Reason: err.Error(),
64
+ Source: apierrors.InvalidParamSourceQuery,
65
+ },
66
+ })
67
+ }
68
+ orderBy, err = FromAPICustomerSortField(ctx, sort.Field)
69
+ if err != nil {
70
+ return ListCustomersRequest{}, err
71
+ }
72
+ order = sort.Order.ToSortxOrder()
73
+ }
74
+
75
+ req := ListCustomersRequest{
76
+ Namespace: ns,
77
+ Page: page,
78
+ OrderBy: orderBy,
79
+ Order: order,
80
+ }
81
+
82
+ if params.Filter != nil {
83
+ key, err := filters.FromAPIFilterString(params.Filter.Key)
84
+ if err != nil {
85
+ return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
86
+ {Field: "filter[key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
87
+ })
88
+ }
89
+ req.Key = key
90
+ name, err := filters.FromAPIFilterString(params.Filter.Name)
91
+ if err != nil {
92
+ return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
93
+ {Field: "filter[name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
94
+ })
95
+ }
96
+ req.Name = name
97
+ primaryEmail, err := filters.FromAPIFilterString(params.Filter.PrimaryEmail)
98
+ if err != nil {
99
+ return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
100
+ {Field: "filter[primary_email]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
101
+ })
102
+ }
103
+ req.PrimaryEmail = primaryEmail
104
+ usageAttributionSubjectKey, err := filters.FromAPIFilterString(params.Filter.UsageAttributionSubjectKey)
105
+ if err != nil {
106
+ return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
107
+ {Field: "filter[usage_attribution_subject_key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
108
+ })
109
+ }
110
+ req.UsageAttributionSubjectKey = usageAttributionSubjectKey
111
+ planKey, err := filters.FromAPIFilterString(params.Filter.PlanKey)
112
+ if err != nil {
113
+ return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
114
+ {Field: "filter[plan_key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
115
+ })
116
+ }
117
+ req.PlanKey = planKey
118
+ billingProfileID, err := filters.FromAPIFilterULID(params.Filter.BillingProfileId)
119
+ if err != nil {
120
+ return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
121
+ {Field: "filter[billing_profile_id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
122
+ })
123
+ }
124
+ req.BillingProfileID = billingProfileID
125
+ }
126
+
127
+ return req, nil
128
+ },
129
+ func(ctx context.Context, request ListCustomersRequest) (ListCustomersResponse, error) {
130
+ resp, err := h.service.ListCustomers(ctx, request)
131
+ if err != nil {
132
+ return ListCustomersResponse{}, fmt.Errorf("failed to list customers: %w", err)
133
+ }
134
+
135
+ customers := lo.Map(resp.Items, func(item customer.Customer, _ int) api.BillingCustomer {
136
+ return ToAPIBillingCustomer(item)
137
+ })
138
+
139
+ r := response.NewPagePaginationResponse(customers, response.PageMetaPage{
140
+ Size: request.Page.PageSize,
141
+ Number: request.Page.PageNumber,
142
+ Total: lo.ToPtr(resp.TotalCount),
143
+ })
144
+
145
+ return r, nil
146
+ },
147
+ commonhttp.JSONResponseEncoderWithStatus[ListCustomersResponse](http.StatusOK),
148
+ httptransport.AppendOptions(
149
+ h.options,
150
+ httptransport.WithOperationName("list-customers"),
151
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
152
+ )...,
153
+ )
154
+ }
api/v3/handlers/customers/upsert.go ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package customers
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/http"
7
+
8
+ api "github.com/openmeterio/openmeter/api/v3"
9
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
10
+ "github.com/openmeterio/openmeter/api/v3/request"
11
+ "github.com/openmeterio/openmeter/openmeter/customer"
12
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
13
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
14
+ )
15
+
16
+ type (
17
+ UpsertCustomerRequest struct {
18
+ Namespace string
19
+ CustomerID string
20
+ CustomerMutate customer.CustomerMutate
21
+ }
22
+ UpsertCustomerParams = string
23
+ UpsertCustomerResponse = api.BillingCustomer
24
+ UpsertCustomerHandler httptransport.HandlerWithArgs[UpsertCustomerRequest, UpsertCustomerResponse, UpsertCustomerParams]
25
+ )
26
+
27
+ // UpsertCustomer returns a handler for updating a customer.
28
+ func (h *handler) UpsertCustomer() UpsertCustomerHandler {
29
+ return httptransport.NewHandlerWithArgs(
30
+ func(ctx context.Context, r *http.Request, customerID UpsertCustomerParams) (UpsertCustomerRequest, error) {
31
+ body := api.UpsertCustomerRequest{}
32
+ if err := request.ParseBody(r, &body); err != nil {
33
+ return UpsertCustomerRequest{}, err
34
+ }
35
+
36
+ ns, err := h.resolveNamespace(ctx)
37
+ if err != nil {
38
+ return UpsertCustomerRequest{}, err
39
+ }
40
+
41
+ customerMutate, err := FromAPIUpsertCustomerRequest(body)
42
+ if err != nil {
43
+ return UpsertCustomerRequest{}, err
44
+ }
45
+
46
+ req := UpsertCustomerRequest{
47
+ Namespace: ns,
48
+ CustomerID: customerID,
49
+ // Key cannot be updated according to api.UpsertCustomerRequest.
50
+ // Therefore, at this point we don't have a key yet. It is ignored in this conversion.
51
+ CustomerMutate: customerMutate,
52
+ }
53
+
54
+ return req, nil
55
+ },
56
+ func(ctx context.Context, request UpsertCustomerRequest) (UpsertCustomerResponse, error) {
57
+ // FIXME: begin
58
+ // Normally, we should avoid putting business logic in handlers, and we should avoid such extra
59
+ // round-trips, since service.UpdateCustomer performs similar checks under the hood.
60
+ // However, in v3 customer API, key cannot be updated, and service.UpdateCustomer will wipe the key in the
61
+ // DB if it was nil in the input. Therefore, in order to avoid wiping the key, we need to get
62
+ // and "backfill" it from the existing customer entity.
63
+ cus, err := h.service.GetCustomer(ctx, customer.GetCustomerInput{
64
+ CustomerID: &customer.CustomerID{
65
+ ID: request.CustomerID,
66
+ Namespace: request.Namespace,
67
+ },
68
+ })
69
+ if err != nil {
70
+ return UpsertCustomerResponse{}, err
71
+ }
72
+
73
+ if cus.IsDeleted() {
74
+ return UpsertCustomerResponse{},
75
+ apierrors.NewGoneError(
76
+ ctx,
77
+ errors.New("customer is deleted"),
78
+ )
79
+ }
80
+
81
+ // Use the key from the just retrieved customer, as it is required by the UpdateCustomer service method.
82
+ request.CustomerMutate.Key = cus.Key
83
+ // FIXME: end
84
+
85
+ updatedCustomer, err := h.service.UpdateCustomer(ctx, customer.UpdateCustomerInput{
86
+ CustomerID: cus.GetID(),
87
+ CustomerMutate: request.CustomerMutate,
88
+ })
89
+ if err != nil {
90
+ return UpsertCustomerResponse{}, err
91
+ }
92
+
93
+ if updatedCustomer == nil {
94
+ return UpsertCustomerResponse{}, errors.New("failed to update customer")
95
+ }
96
+
97
+ return ToAPIBillingCustomer(*updatedCustomer), nil
98
+ },
99
+ commonhttp.JSONResponseEncoderWithStatus[UpsertCustomerResponse](http.StatusOK),
100
+ httptransport.AppendOptions(
101
+ h.options,
102
+ httptransport.WithOperationName("upsert-customer"),
103
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
104
+ )...,
105
+ )
106
+ }
api/v3/handlers/events/convert.go ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package events
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "strings"
9
+
10
+ "github.com/oapi-codegen/nullable"
11
+ "github.com/samber/lo"
12
+
13
+ api "github.com/openmeterio/openmeter/api/v3"
14
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
15
+ "github.com/openmeterio/openmeter/api/v3/request"
16
+ "github.com/openmeterio/openmeter/openmeter/meterevent"
17
+ "github.com/openmeterio/openmeter/openmeter/streaming"
18
+ "github.com/openmeterio/openmeter/pkg/filter"
19
+ "github.com/openmeterio/openmeter/pkg/sortx"
20
+ )
21
+
22
+ const cloudEventsSpecVersion = "1.0"
23
+
24
+ // toAPIMeteringIngestedEvent converts a meterevent.Event to its API wire form.
25
+ func toAPIMeteringIngestedEvent(e meterevent.Event) (api.MeteringIngestedEvent, error) {
26
+ event := api.MeteringEvent{
27
+ Id: e.ID,
28
+ Source: e.Source,
29
+ Specversion: cloudEventsSpecVersion,
30
+ Subject: e.Subject,
31
+ Type: e.Type,
32
+ Time: nullable.NewNullableWithValue[api.DateTime](e.Time),
33
+ }
34
+
35
+ if e.Data != "" {
36
+ var data map[string]any
37
+ if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
38
+ return api.MeteringIngestedEvent{}, fmt.Errorf("parse event data as json: %w", err)
39
+ }
40
+ event.Data = nullable.NewNullableWithValue(data)
41
+ event.Datacontenttype = nullable.NewNullableWithValue[api.MeteringEventDatacontenttype](api.MeteringEventDatacontenttype("application/json"))
42
+ }
43
+
44
+ return api.MeteringIngestedEvent{
45
+ Event: event,
46
+ Customer: toAPICustomerReference(e.CustomerID),
47
+ IngestedAt: e.IngestedAt,
48
+ StoredAt: e.StoredAt,
49
+ ValidationErrors: toAPIMeteringIngestedEventValidationErrors(e.ValidationErrors),
50
+ }, nil
51
+ }
52
+
53
+ func toAPICustomerReference(id *string) *api.CustomerReference {
54
+ if id == nil || *id == "" {
55
+ return nil
56
+ }
57
+ return &api.CustomerReference{Id: *id}
58
+ }
59
+
60
+ func toAPIMeteringIngestedEventValidationErrors(errs []error) *[]api.MeteringIngestedEventValidationError {
61
+ if len(errs) == 0 {
62
+ return nil
63
+ }
64
+
65
+ result := lo.FilterMap(errs, func(err error, _ int) (api.MeteringIngestedEventValidationError, bool) {
66
+ if err == nil {
67
+ return api.MeteringIngestedEventValidationError{}, false
68
+ }
69
+
70
+ return api.MeteringIngestedEventValidationError{
71
+ Code: "validation_error",
72
+ Message: err.Error(),
73
+ }, true
74
+ })
75
+
76
+ if len(result) == 0 {
77
+ return nil
78
+ }
79
+
80
+ return lo.ToPtr(result)
81
+ }
82
+
83
+ // fromAPICustomerIDFilter maps the v3 customer_id filter to the backend filter,
84
+ // rejecting every operator that the underlying service cannot evaluate. Only
85
+ // `eq` and `oeq` are supported because ListEventsV2Params requires a concrete
86
+ // IN set.
87
+ func fromAPICustomerIDFilter(ctx context.Context, f *api.ULIDFieldFilter) (*filter.FilterString, error) {
88
+ if f == nil {
89
+ return nil, nil
90
+ }
91
+
92
+ if f.Neq != nil {
93
+ err := errors.New("only eq and oeq operators are supported")
94
+ return nil, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
95
+ {
96
+ Field: "filter[customer_id]",
97
+ Reason: err.Error(),
98
+ Source: apierrors.InvalidParamSourceQuery,
99
+ },
100
+ })
101
+ }
102
+
103
+ var values []string
104
+ if f.Eq != nil {
105
+ values = append(values, *f.Eq)
106
+ }
107
+ if len(f.Oeq) > 0 {
108
+ values = append(values, f.Oeq...)
109
+ }
110
+
111
+ if len(values) == 0 {
112
+ return nil, nil
113
+ }
114
+
115
+ return &filter.FilterString{In: &values}, nil
116
+ }
117
+
118
+ func FromAPIEventSortField(ctx context.Context, field string) (streaming.EventSortField, error) {
119
+ switch field {
120
+ case "time":
121
+ return streaming.EventSortFieldTime, nil
122
+ case "ingested_at":
123
+ return streaming.EventSortFieldIngestedAt, nil
124
+ case "stored_at":
125
+ return streaming.EventSortFieldStoredAt, nil
126
+ default:
127
+ return "", apierrors.NewUnsupportedSortFieldError(
128
+ ctx, field, "time", "ingested_at", "stored_at",
129
+ )
130
+ }
131
+ }
132
+
133
+ // fromAPIEventSort resolves the public sort query into a backend sort field and direction.
134
+ func fromAPIEventSort(ctx context.Context, sort *api.SortQuery) (streaming.EventSortField, sortx.Order, error) {
135
+ if lo.FromPtr(sort) == "" {
136
+ return "", "", nil
137
+ }
138
+
139
+ parsed, err := request.ParseSortBy(*sort)
140
+ if err != nil {
141
+ return "", "", apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
142
+ {
143
+ Field: "sort",
144
+ Reason: err.Error(),
145
+ Source: apierrors.InvalidParamSourceQuery,
146
+ },
147
+ })
148
+ }
149
+
150
+ field, err := FromAPIEventSortField(ctx, parsed.Field)
151
+ if err != nil {
152
+ return "", "", err
153
+ }
154
+
155
+ // If the caller did not supply an explicit asc/desc suffix, default to
156
+ // descending so `sort=time` behaves the same as omitting the parameter
157
+ // (most recent first).
158
+ order := parsed.Order.ToSortxOrder()
159
+ if len(strings.Fields(*sort)) == 1 {
160
+ order = sortx.OrderDesc
161
+ }
162
+
163
+ return field, order, nil
164
+ }
api/v3/handlers/events/handler.go ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package events
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/ingest"
7
+ "github.com/openmeterio/openmeter/openmeter/meterevent"
8
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
9
+ )
10
+
11
+ type Handler interface {
12
+ IngestEvents() IngestEventsHandler
13
+ ListMeteringEvents() ListMeteringEventsHandler
14
+ }
15
+
16
+ type handler struct {
17
+ resolveNamespace func(ctx context.Context) (string, error)
18
+ service ingest.Service
19
+ metereventService meterevent.Service
20
+ options []httptransport.HandlerOption
21
+ }
22
+
23
+ func New(
24
+ resolveNamespace func(ctx context.Context) (string, error),
25
+ service ingest.Service,
26
+ metereventService meterevent.Service,
27
+ options ...httptransport.HandlerOption,
28
+ ) Handler {
29
+ return &handler{
30
+ resolveNamespace: resolveNamespace,
31
+ service: service,
32
+ metereventService: metereventService,
33
+ options: options,
34
+ }
35
+ }
api/v3/handlers/events/ingest.go ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package events
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "mime"
8
+ "net/http"
9
+
10
+ "github.com/cloudevents/sdk-go/v2/event"
11
+
12
+ "github.com/openmeterio/openmeter/api"
13
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
14
+ "github.com/openmeterio/openmeter/openmeter/ingest"
15
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
16
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
17
+ )
18
+
19
+ type (
20
+ IngestEventsRequest = ingest.IngestEventsRequest
21
+ IngestEventsResponse = *struct{}
22
+ IngestEventsHandler httptransport.Handler[ingest.IngestEventsRequest, IngestEventsResponse]
23
+ )
24
+
25
+ func (h *handler) IngestEvents() IngestEventsHandler {
26
+ return httptransport.NewHandler(
27
+ func(ctx context.Context, r *http.Request) (ingest.IngestEventsRequest, error) {
28
+ req := ingest.IngestEventsRequest{}
29
+
30
+ namespace, err := h.resolveNamespace(ctx)
31
+ if err != nil {
32
+ return req, err
33
+ }
34
+
35
+ req.Namespace = namespace
36
+
37
+ contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
38
+ if err != nil {
39
+ return req, apierrors.NewBadRequestError(ctx, err, nil)
40
+ }
41
+
42
+ switch contentType {
43
+ case "application/json":
44
+ var apiRequest api.IngestEventsBody
45
+
46
+ err := json.NewDecoder(r.Body).Decode(&apiRequest)
47
+ if err != nil {
48
+ return req, apierrors.NewBadRequestError(ctx, err, nil)
49
+ }
50
+
51
+ // Try to parse as a single event
52
+ e, err := apiRequest.AsEvent()
53
+ if err == nil {
54
+ req.Events = []event.Event{e}
55
+ } else {
56
+ // Try to parse as a batch of events
57
+ e, err := apiRequest.AsIngestEventsBody1()
58
+ if err == nil {
59
+ req.Events = e
60
+ }
61
+ }
62
+
63
+ // If we still don't have any events, return an error
64
+ if len(req.Events) == 0 {
65
+ return req, apierrors.NewBadRequestError(ctx, errors.New("no events found"), nil)
66
+ }
67
+ case "application/cloudevents+json":
68
+ var apiRequest api.IngestEventsApplicationCloudeventsPlusJSONRequestBody
69
+
70
+ err := json.NewDecoder(r.Body).Decode(&apiRequest)
71
+ if err != nil {
72
+ return req, apierrors.NewBadRequestError(ctx, err, nil)
73
+ }
74
+
75
+ req.Events = []event.Event{apiRequest}
76
+ case "application/cloudevents-batch+json":
77
+ var apiRequest api.IngestEventsApplicationCloudeventsBatchPlusJSONBody
78
+
79
+ err := json.NewDecoder(r.Body).Decode(&apiRequest)
80
+ if err != nil {
81
+ return req, apierrors.NewBadRequestError(ctx, err, nil)
82
+ }
83
+
84
+ req.Events = apiRequest
85
+ default:
86
+ return req, apierrors.NewBadRequestError(ctx, errors.New("invalid content type"), nil)
87
+ }
88
+
89
+ return req, nil
90
+ },
91
+ func(ctx context.Context, request ingest.IngestEventsRequest) (IngestEventsResponse, error) {
92
+ _, err := h.service.IngestEvents(ctx, request)
93
+ if err != nil {
94
+ return nil, err
95
+ }
96
+
97
+ return nil, nil
98
+ },
99
+ commonhttp.EmptyResponseEncoder[IngestEventsResponse](http.StatusAccepted),
100
+ httptransport.AppendOptions(
101
+ h.options,
102
+ httptransport.WithOperationName("ingest-metering-events"),
103
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
104
+ )...,
105
+ )
106
+ }
api/v3/handlers/events/list.go ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package events
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+
9
+ "github.com/samber/lo"
10
+
11
+ api "github.com/openmeterio/openmeter/api/v3"
12
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
13
+ "github.com/openmeterio/openmeter/api/v3/filters"
14
+ "github.com/openmeterio/openmeter/api/v3/response"
15
+ "github.com/openmeterio/openmeter/openmeter/meterevent"
16
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
17
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
18
+ pagination "github.com/openmeterio/openmeter/pkg/pagination/v2"
19
+ "github.com/openmeterio/openmeter/pkg/slicesx"
20
+ )
21
+
22
+ const defaultListMeteringEventsPageSize = 20
23
+
24
+ type (
25
+ ListMeteringEventsRequest = meterevent.ListEventsV2Params
26
+ ListMeteringEventsResponse = response.CursorPaginationResponse[api.MeteringIngestedEvent]
27
+ ListMeteringEventsParams = api.ListMeteringEventsParams
28
+ ListMeteringEventsHandler httptransport.HandlerWithArgs[ListMeteringEventsRequest, ListMeteringEventsResponse, ListMeteringEventsParams]
29
+ )
30
+
31
+ func (h *handler) ListMeteringEvents() ListMeteringEventsHandler {
32
+ return httptransport.NewHandlerWithArgs(
33
+ func(ctx context.Context, r *http.Request, params ListMeteringEventsParams) (ListMeteringEventsRequest, error) {
34
+ ns, err := h.resolveNamespace(ctx)
35
+ if err != nil {
36
+ return ListMeteringEventsRequest{}, err
37
+ }
38
+
39
+ req := ListMeteringEventsRequest{
40
+ Namespace: ns,
41
+ }
42
+
43
+ pageSize := defaultListMeteringEventsPageSize
44
+ if params.Page != nil {
45
+ if params.Page.Before != nil {
46
+ return ListMeteringEventsRequest{}, apierrors.NewBadRequestError(ctx, errors.New("page[before] is not supported"), apierrors.InvalidParameters{
47
+ {
48
+ Field: "page[before]",
49
+ Reason: "backward pagination is not supported",
50
+ Source: apierrors.InvalidParamSourceQuery,
51
+ },
52
+ })
53
+ }
54
+
55
+ if params.Page.After != nil {
56
+ cursor, err := pagination.DecodeCursor(*params.Page.After)
57
+ if err != nil {
58
+ return ListMeteringEventsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
59
+ {
60
+ Field: "page[after]",
61
+ Reason: err.Error(),
62
+ Source: apierrors.InvalidParamSourceQuery,
63
+ },
64
+ })
65
+ }
66
+ req.Cursor = cursor
67
+ }
68
+
69
+ if params.Page.Size != nil {
70
+ pageSize = *params.Page.Size
71
+ }
72
+ }
73
+
74
+ if pageSize < 1 || pageSize > meterevent.MaximumLimit {
75
+ return ListMeteringEventsRequest{}, apierrors.NewBadRequestError(ctx, fmt.Errorf("page[size] must be between 1 and %d", meterevent.MaximumLimit), apierrors.InvalidParameters{
76
+ {
77
+ Field: "page[size]",
78
+ Reason: fmt.Sprintf("must be between 1 and %d", meterevent.MaximumLimit),
79
+ Source: apierrors.InvalidParamSourceQuery,
80
+ },
81
+ })
82
+ }
83
+ req.Limit = lo.ToPtr(pageSize)
84
+
85
+ if params.Filter != nil {
86
+ if err := applyFilters(ctx, &req, params.Filter); err != nil {
87
+ return ListMeteringEventsRequest{}, err
88
+ }
89
+ }
90
+
91
+ sortBy, sortOrder, err := fromAPIEventSort(ctx, params.Sort)
92
+ if err != nil {
93
+ return ListMeteringEventsRequest{}, err
94
+ }
95
+ req.SortBy = sortBy
96
+ req.SortOrder = sortOrder
97
+
98
+ return req, nil
99
+ },
100
+ func(ctx context.Context, req ListMeteringEventsRequest) (ListMeteringEventsResponse, error) {
101
+ result, err := h.metereventService.ListEventsV2(ctx, req)
102
+ if err != nil {
103
+ return ListMeteringEventsResponse{}, err
104
+ }
105
+
106
+ items, err := slicesx.MapWithErr(result.Items, toAPIMeteringIngestedEvent)
107
+ if err != nil {
108
+ return ListMeteringEventsResponse{}, fmt.Errorf("convert events: %w", err)
109
+ }
110
+
111
+ pageSize := lo.FromPtrOr(req.Limit, defaultListMeteringEventsPageSize)
112
+ resp := response.NewCursorPaginationResponse(result.Items, pageSize)
113
+
114
+ return ListMeteringEventsResponse{
115
+ Data: items,
116
+ Meta: resp.Meta,
117
+ }, nil
118
+ },
119
+ commonhttp.JSONResponseEncoderWithStatus[ListMeteringEventsResponse](http.StatusOK),
120
+ httptransport.AppendOptions(
121
+ h.options,
122
+ httptransport.WithOperationName("list-metering-events"),
123
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
124
+ )...,
125
+ )
126
+ }
127
+
128
+ func applyFilters(ctx context.Context, req *ListMeteringEventsRequest, f *api.ListEventsParamsFilter) error {
129
+ id, err := filters.FromAPIFilterString(f.Id)
130
+ if err != nil {
131
+ return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
132
+ {
133
+ Field: "filter[id]",
134
+ Reason: err.Error(),
135
+ Source: apierrors.InvalidParamSourceQuery,
136
+ },
137
+ })
138
+ }
139
+ req.ID = id
140
+
141
+ source, err := filters.FromAPIFilterString(f.Source)
142
+ if err != nil {
143
+ return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
144
+ {
145
+ Field: "filter[source]",
146
+ Reason: err.Error(),
147
+ Source: apierrors.InvalidParamSourceQuery,
148
+ },
149
+ })
150
+ }
151
+ req.Source = source
152
+
153
+ subject, err := filters.FromAPIFilterString(f.Subject)
154
+ if err != nil {
155
+ return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
156
+ {
157
+ Field: "filter[subject]",
158
+ Reason: err.Error(),
159
+ Source: apierrors.InvalidParamSourceQuery,
160
+ },
161
+ })
162
+ }
163
+ req.Subject = subject
164
+
165
+ typeFilter, err := filters.FromAPIFilterString(f.Type)
166
+ if err != nil {
167
+ return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
168
+ {
169
+ Field: "filter[type]",
170
+ Reason: err.Error(),
171
+ Source: apierrors.InvalidParamSourceQuery,
172
+ },
173
+ })
174
+ }
175
+ req.Type = typeFilter
176
+
177
+ timeFilter, err := filters.FromAPIFilterDateTime(f.Time)
178
+ if err != nil {
179
+ return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
180
+ {
181
+ Field: "filter[time]",
182
+ Reason: err.Error(),
183
+ Source: apierrors.InvalidParamSourceQuery,
184
+ },
185
+ })
186
+ }
187
+ req.Time = timeFilter
188
+
189
+ ingestedAt, err := filters.FromAPIFilterDateTime(f.IngestedAt)
190
+ if err != nil {
191
+ return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
192
+ {
193
+ Field: "filter[ingested_at]",
194
+ Reason: err.Error(),
195
+ Source: apierrors.InvalidParamSourceQuery,
196
+ },
197
+ })
198
+ }
199
+ req.IngestedAt = ingestedAt
200
+
201
+ storedAt, err := filters.FromAPIFilterDateTime(f.StoredAt)
202
+ if err != nil {
203
+ return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
204
+ {
205
+ Field: "filter[stored_at]",
206
+ Reason: err.Error(),
207
+ Source: apierrors.InvalidParamSourceQuery,
208
+ },
209
+ })
210
+ }
211
+ req.StoredAt = storedAt
212
+
213
+ customerID, err := fromAPICustomerIDFilter(ctx, f.CustomerId)
214
+ if err != nil {
215
+ return err
216
+ }
217
+ req.CustomerID = customerID
218
+
219
+ return nil
220
+ }
api/v3/handlers/events/list_test.go ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package events
2
+
3
+ import (
4
+ "errors"
5
+ "testing"
6
+
7
+ "github.com/samber/lo"
8
+ "github.com/stretchr/testify/require"
9
+
10
+ api "github.com/openmeterio/openmeter/api/v3"
11
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
12
+ "github.com/openmeterio/openmeter/openmeter/streaming"
13
+ "github.com/openmeterio/openmeter/pkg/sortx"
14
+ )
15
+
16
+ func TestFromAPICustomerIDFilter(t *testing.T) {
17
+ ctx := t.Context()
18
+
19
+ t.Run("nil filter returns nil", func(t *testing.T) {
20
+ out, err := fromAPICustomerIDFilter(ctx, nil)
21
+ require.NoError(t, err)
22
+ require.Nil(t, out)
23
+ })
24
+
25
+ t.Run("eq maps to In with one element", func(t *testing.T) {
26
+ out, err := fromAPICustomerIDFilter(ctx, &api.ULIDFieldFilter{Eq: lo.ToPtr("01G65Z755AFWAKHE12NY0CQ9FH")})
27
+ require.NoError(t, err)
28
+ require.NotNil(t, out)
29
+ require.NotNil(t, out.In)
30
+ require.Equal(t, []string{"01G65Z755AFWAKHE12NY0CQ9FH"}, *out.In)
31
+ })
32
+
33
+ t.Run("oeq maps to In", func(t *testing.T) {
34
+ out, err := fromAPICustomerIDFilter(ctx, &api.ULIDFieldFilter{Oeq: []string{"a", "b"}})
35
+ require.NoError(t, err)
36
+ require.NotNil(t, out)
37
+ require.Equal(t, []string{"a", "b"}, *out.In)
38
+ })
39
+
40
+ t.Run("neq is rejected", func(t *testing.T) {
41
+ _, err := fromAPICustomerIDFilter(ctx, &api.ULIDFieldFilter{Neq: lo.ToPtr("x")})
42
+ require.Error(t, err)
43
+ assertBadRequestField(t, err, "filter[customer_id]")
44
+ })
45
+ }
46
+
47
+ func TestFromAPIEventSort(t *testing.T) {
48
+ ctx := t.Context()
49
+
50
+ t.Run("nil returns empty values", func(t *testing.T) {
51
+ field, order, err := fromAPIEventSort(ctx, nil)
52
+ require.NoError(t, err)
53
+ require.Equal(t, streaming.EventSortField(""), field)
54
+ require.Equal(t, sortx.Order(""), order)
55
+ })
56
+
57
+ t.Run("time defaults to desc when no suffix", func(t *testing.T) {
58
+ sort := api.SortQuery("time")
59
+ field, order, err := fromAPIEventSort(ctx, &sort)
60
+ require.NoError(t, err)
61
+ require.Equal(t, streaming.EventSortFieldTime, field)
62
+ require.Equal(t, sortx.OrderDesc, order)
63
+ })
64
+
65
+ t.Run("ingested_at desc", func(t *testing.T) {
66
+ sort := api.SortQuery("ingested_at desc")
67
+ field, order, err := fromAPIEventSort(ctx, &sort)
68
+ require.NoError(t, err)
69
+ require.Equal(t, streaming.EventSortFieldIngestedAt, field)
70
+ require.Equal(t, sortx.OrderDesc, order)
71
+ })
72
+
73
+ t.Run("stored_at defaults to desc when no suffix", func(t *testing.T) {
74
+ sort := api.SortQuery("stored_at")
75
+ field, order, err := fromAPIEventSort(ctx, &sort)
76
+ require.NoError(t, err)
77
+ require.Equal(t, streaming.EventSortFieldStoredAt, field)
78
+ require.Equal(t, sortx.OrderDesc, order)
79
+ })
80
+
81
+ t.Run("time asc suffix is honored", func(t *testing.T) {
82
+ sort := api.SortQuery("time asc")
83
+ field, order, err := fromAPIEventSort(ctx, &sort)
84
+ require.NoError(t, err)
85
+ require.Equal(t, streaming.EventSortFieldTime, field)
86
+ require.Equal(t, sortx.OrderAsc, order)
87
+ })
88
+
89
+ t.Run("unknown field is rejected", func(t *testing.T) {
90
+ sort := api.SortQuery("created_at")
91
+ _, _, err := fromAPIEventSort(ctx, &sort)
92
+ require.Error(t, err)
93
+ assertBadRequestField(t, err, "sort")
94
+ })
95
+
96
+ t.Run("malformed input is rejected", func(t *testing.T) {
97
+ sort := api.SortQuery("time bogus extra")
98
+ _, _, err := fromAPIEventSort(ctx, &sort)
99
+ require.Error(t, err)
100
+ assertBadRequestField(t, err, "sort")
101
+ })
102
+ }
103
+
104
+ func assertBadRequestField(t *testing.T, err error, field string) {
105
+ t.Helper()
106
+ var apiErr *apierrors.BaseAPIError
107
+ require.True(t, errors.As(err, &apiErr), "expected *apierrors.BaseAPIError, got %T", err)
108
+ require.Len(t, apiErr.InvalidParameters, 1)
109
+ require.Equal(t, field, apiErr.InvalidParameters[0].Field)
110
+ }
api/v3/handlers/featurecost/convert.go ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package featurecost
2
+
3
+ import (
4
+ "github.com/oapi-codegen/nullable"
5
+ "github.com/samber/lo"
6
+
7
+ api "github.com/openmeterio/openmeter/api/v3"
8
+ "github.com/openmeterio/openmeter/api/v3/handlers/meters/query"
9
+ "github.com/openmeterio/openmeter/openmeter/cost"
10
+ )
11
+
12
+ // ToAPIFeatureCostQueryResult converts a domain CostQueryResult to the v3 API response.
13
+ func ToAPIFeatureCostQueryResult(result *cost.CostQueryResult, body api.MeterQueryRequest) api.FeatureCostQueryResult {
14
+ if result == nil {
15
+ return api.FeatureCostQueryResult{
16
+ From: body.From,
17
+ To: body.To,
18
+ Data: []api.FeatureCostQueryRow{},
19
+ }
20
+ }
21
+ return api.FeatureCostQueryResult{
22
+ From: body.From,
23
+ To: body.To,
24
+ Data: lo.Map(result.Rows, func(row cost.CostQueryRow, _ int) api.FeatureCostQueryRow {
25
+ return toAPIFeatureCostQueryRow(row)
26
+ }),
27
+ }
28
+ }
29
+
30
+ func toAPIFeatureCostQueryRow(row cost.CostQueryRow) api.FeatureCostQueryRow {
31
+ dimensions := make(map[string]string)
32
+
33
+ if row.Subject != nil {
34
+ dimensions[query.DimensionSubject] = *row.Subject
35
+ }
36
+
37
+ if row.CustomerID != nil {
38
+ dimensions[query.DimensionCustomerID] = *row.CustomerID
39
+ }
40
+
41
+ for key, value := range row.GroupBy {
42
+ if key == query.DimensionSubject || key == query.DimensionCustomerID {
43
+ continue
44
+ }
45
+ if value != nil {
46
+ dimensions[key] = *value
47
+ }
48
+ }
49
+
50
+ apiRow := api.FeatureCostQueryRow{
51
+ Usage: row.Usage.String(),
52
+ Currency: api.CurrencyCode(row.Currency),
53
+ From: row.WindowStart,
54
+ To: row.WindowEnd,
55
+ Dimensions: dimensions,
56
+ }
57
+
58
+ if row.Cost != nil {
59
+ apiRow.Cost = nullable.NewNullableWithValue(row.Cost.String())
60
+ } else {
61
+ apiRow.Cost = nullable.NewNullNullable[api.Numeric]()
62
+ }
63
+
64
+ if row.Detail != "" {
65
+ apiRow.Detail = lo.ToPtr(row.Detail)
66
+ }
67
+
68
+ return apiRow
69
+ }
api/v3/handlers/featurecost/handler.go ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package featurecost
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/cost"
7
+ "github.com/openmeterio/openmeter/openmeter/customer"
8
+ "github.com/openmeterio/openmeter/openmeter/meter"
9
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
10
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
11
+ )
12
+
13
+ type Handler interface {
14
+ QueryFeatureCost() QueryFeatureCostHandler
15
+ }
16
+
17
+ type handler struct {
18
+ resolveNamespace func(ctx context.Context) (string, error)
19
+ costService cost.Service
20
+ featureConnector feature.FeatureConnector
21
+ meterService meter.Service
22
+ customerService customer.Service
23
+ options []httptransport.HandlerOption
24
+ }
25
+
26
+ func New(
27
+ resolveNamespace func(ctx context.Context) (string, error),
28
+ costService cost.Service,
29
+ featureConnector feature.FeatureConnector,
30
+ meterService meter.Service,
31
+ customerService customer.Service,
32
+ options ...httptransport.HandlerOption,
33
+ ) Handler {
34
+ return &handler{
35
+ resolveNamespace: resolveNamespace,
36
+ costService: costService,
37
+ featureConnector: featureConnector,
38
+ meterService: meterService,
39
+ customerService: customerService,
40
+ options: options,
41
+ }
42
+ }
api/v3/handlers/featurecost/query.go ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package featurecost
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+
8
+ api "github.com/openmeterio/openmeter/api/v3"
9
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
10
+ "github.com/openmeterio/openmeter/api/v3/handlers/meters/query"
11
+ "github.com/openmeterio/openmeter/api/v3/request"
12
+ "github.com/openmeterio/openmeter/openmeter/cost"
13
+ "github.com/openmeterio/openmeter/openmeter/meter"
14
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
15
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
16
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
17
+ "github.com/openmeterio/openmeter/pkg/models"
18
+ )
19
+
20
+ type (
21
+ QueryFeatureCostRequest struct {
22
+ Namespace string
23
+ FeatureID string
24
+ Body api.MeterQueryRequest
25
+ }
26
+ QueryFeatureCostResponse = api.FeatureCostQueryResult
27
+ QueryFeatureCostParams = string
28
+ QueryFeatureCostHandler httptransport.HandlerWithArgs[QueryFeatureCostRequest, QueryFeatureCostResponse, QueryFeatureCostParams]
29
+ )
30
+
31
+ func (h *handler) QueryFeatureCost() QueryFeatureCostHandler {
32
+ return httptransport.NewHandlerWithArgs(
33
+ func(ctx context.Context, r *http.Request, featureID QueryFeatureCostParams) (QueryFeatureCostRequest, error) {
34
+ ns, err := h.resolveNamespace(ctx)
35
+ if err != nil {
36
+ return QueryFeatureCostRequest{}, err
37
+ }
38
+
39
+ var body api.MeterQueryRequest
40
+ if err := request.ParseOptionalBody(r, &body); err != nil {
41
+ return QueryFeatureCostRequest{}, err
42
+ }
43
+
44
+ return QueryFeatureCostRequest{
45
+ Namespace: ns,
46
+ FeatureID: featureID,
47
+ Body: body,
48
+ }, nil
49
+ },
50
+ func(ctx context.Context, req QueryFeatureCostRequest) (QueryFeatureCostResponse, error) {
51
+ // Get the feature to find its meter
52
+ feat, err := h.featureConnector.GetFeature(ctx, req.Namespace, req.FeatureID, feature.IncludeArchivedFeatureFalse)
53
+ if err != nil {
54
+ return QueryFeatureCostResponse{}, err
55
+ }
56
+
57
+ if feat.MeterID == nil {
58
+ return QueryFeatureCostResponse{}, models.NewGenericValidationError(
59
+ fmt.Errorf("feature %s has no meter associated", feat.Key),
60
+ )
61
+ }
62
+
63
+ // Get the meter for query param validation
64
+ m, err := h.meterService.GetMeterByIDOrSlug(ctx, meter.GetMeterInput{
65
+ Namespace: req.Namespace,
66
+ IDOrSlug: *feat.MeterID,
67
+ })
68
+ if err != nil {
69
+ return QueryFeatureCostResponse{}, fmt.Errorf("failed to get meter: %w", err)
70
+ }
71
+
72
+ // Build streaming query params using shared logic
73
+ params, err := query.BuildQueryParams(ctx, m, req.Body, query.NewCustomerResolver(h.customerService))
74
+ if err != nil {
75
+ return QueryFeatureCostResponse{}, err
76
+ }
77
+
78
+ // Query feature cost
79
+ result, err := h.costService.QueryFeatureCost(ctx, cost.QueryFeatureCostInput{
80
+ Namespace: req.Namespace,
81
+ FeatureID: feat.ID,
82
+ QueryParams: params,
83
+ })
84
+ if err != nil {
85
+ return QueryFeatureCostResponse{}, err
86
+ }
87
+
88
+ return ToAPIFeatureCostQueryResult(result, req.Body), nil
89
+ },
90
+ commonhttp.JSONResponseEncoderWithStatus[QueryFeatureCostResponse](http.StatusOK),
91
+ httptransport.AppendOptions(
92
+ h.options,
93
+ httptransport.WithOperationName("query-feature-cost"),
94
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
95
+ )...,
96
+ )
97
+ }
api/v3/handlers/features/convert.go ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "github.com/alpacahq/alpacadecimal"
8
+ "github.com/oapi-codegen/nullable"
9
+ "github.com/samber/lo"
10
+
11
+ api "github.com/openmeterio/openmeter/api/v3"
12
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
13
+ "github.com/openmeterio/openmeter/api/v3/labels"
14
+ "github.com/openmeterio/openmeter/api/v3/request"
15
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
16
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
17
+ "github.com/openmeterio/openmeter/pkg/filter"
18
+ )
19
+
20
+ func FromAPIFeatureSortField(ctx context.Context, field string) (feature.FeatureOrderBy, error) {
21
+ switch field {
22
+ case "key":
23
+ return feature.FeatureOrderByKey, nil
24
+ case "name":
25
+ return feature.FeatureOrderByName, nil
26
+ case "created_at":
27
+ return feature.FeatureOrderByCreatedAt, nil
28
+ case "updated_at":
29
+ return feature.FeatureOrderByUpdatedAt, nil
30
+ default:
31
+ return "", apierrors.NewUnsupportedSortFieldError(
32
+ ctx, field, "key", "name", "created_at", "updated_at",
33
+ )
34
+ }
35
+ }
36
+
37
+ func convertFeatureToAPI(f feature.Feature) (api.Feature, error) {
38
+ resp := api.Feature{
39
+ Id: f.ID,
40
+ Key: f.Key,
41
+ Name: f.Name,
42
+ Description: f.Description,
43
+ Labels: labels.FromMetadata(f.Metadata),
44
+ CreatedAt: f.CreatedAt,
45
+ UpdatedAt: f.UpdatedAt,
46
+ DeletedAt: f.ArchivedAt,
47
+ }
48
+
49
+ if f.MeterID != nil {
50
+ resp.Meter = &api.FeatureMeterReference{
51
+ Id: *f.MeterID,
52
+ }
53
+
54
+ if len(f.MeterGroupByFilters) > 0 {
55
+ filters := convertFiltersToAPI(f.MeterGroupByFilters)
56
+ resp.Meter.Filters = &filters
57
+ }
58
+ }
59
+
60
+ if f.UnitCost != nil {
61
+ apiUnitCost, err := convertUnitCostToAPI(f.UnitCost)
62
+ if err != nil {
63
+ return api.Feature{}, fmt.Errorf("failed to convert unit cost: %w", err)
64
+ }
65
+ resp.UnitCost = &apiUnitCost
66
+ }
67
+
68
+ return resp, nil
69
+ }
70
+
71
+ func convertCreateRequestToDomain(ns string, body api.CreateFeatureRequest, meterID *string) (feature.CreateFeatureInputs, error) {
72
+ metadata, err := labels.ToMetadata(body.Labels)
73
+ if err != nil {
74
+ return feature.CreateFeatureInputs{}, fmt.Errorf("failed to convert labels: %w", err)
75
+ }
76
+
77
+ inputs := feature.CreateFeatureInputs{
78
+ Namespace: ns,
79
+ Name: body.Name,
80
+ Description: body.Description,
81
+ Key: body.Key,
82
+ MeterID: meterID,
83
+ Metadata: metadata,
84
+ }
85
+
86
+ if body.Meter != nil {
87
+ if body.Meter.Filters != nil {
88
+ inputs.MeterGroupByFilters = convertFiltersFromAPI(*body.Meter.Filters)
89
+ }
90
+ }
91
+
92
+ if body.UnitCost != nil {
93
+ unitCost, err := convertUnitCostFromAPI(body.UnitCost)
94
+ if err != nil {
95
+ return feature.CreateFeatureInputs{}, fmt.Errorf("invalid unit cost: %w", err)
96
+ }
97
+ inputs.UnitCost = unitCost
98
+ }
99
+
100
+ return inputs, nil
101
+ }
102
+
103
+ func convertUpdateRequestToDomain(ns string, featureID string, body api.UpdateFeatureRequest) (feature.UpdateFeatureInputs, error) {
104
+ input := feature.UpdateFeatureInputs{
105
+ Namespace: ns,
106
+ ID: featureID,
107
+ }
108
+
109
+ if body.UnitCost.IsNull() {
110
+ input.UnitCost = nullable.NewNullNullable[feature.UnitCost]()
111
+ } else if body.UnitCost.IsSpecified() {
112
+ v, err := body.UnitCost.Get()
113
+ if err != nil {
114
+ return feature.UpdateFeatureInputs{}, fmt.Errorf("invalid unit cost: %w", err)
115
+ }
116
+ unitCost, err := convertUnitCostFromAPI(&v)
117
+ if err != nil {
118
+ return feature.UpdateFeatureInputs{}, fmt.Errorf("invalid unit cost: %w", err)
119
+ }
120
+ input.UnitCost = nullable.NewNullableWithValue(*unitCost)
121
+ }
122
+
123
+ return input, nil
124
+ }
125
+
126
+ func convertUnitCostToAPI(u *feature.UnitCost) (api.BillingFeatureUnitCost, error) {
127
+ var out api.BillingFeatureUnitCost
128
+
129
+ switch u.Type {
130
+ case feature.UnitCostTypeManual:
131
+ if err := out.FromBillingFeatureManualUnitCost(api.BillingFeatureManualUnitCost{
132
+ Amount: u.Manual.Amount.String(),
133
+ }); err != nil {
134
+ return out, fmt.Errorf("failed to convert manual unit cost: %w", err)
135
+ }
136
+ case feature.UnitCostTypeLLM:
137
+ llmCost := api.BillingFeatureLLMUnitCost{}
138
+ if u.LLM.ProviderProperty != "" {
139
+ llmCost.ProviderProperty = lo.ToPtr(u.LLM.ProviderProperty)
140
+ }
141
+ if u.LLM.Provider != "" {
142
+ llmCost.Provider = lo.ToPtr(u.LLM.Provider)
143
+ }
144
+ if u.LLM.ModelProperty != "" {
145
+ llmCost.ModelProperty = lo.ToPtr(u.LLM.ModelProperty)
146
+ }
147
+ if u.LLM.Model != "" {
148
+ llmCost.Model = lo.ToPtr(u.LLM.Model)
149
+ }
150
+ if u.LLM.TokenTypeProperty != "" {
151
+ llmCost.TokenTypeProperty = lo.ToPtr(u.LLM.TokenTypeProperty)
152
+ }
153
+ if u.LLM.TokenType != "" {
154
+ llmCost.TokenType = lo.ToPtr(api.BillingFeatureLLMTokenType(u.LLM.TokenType))
155
+ }
156
+ if err := out.FromBillingFeatureLLMUnitCost(llmCost); err != nil {
157
+ return out, fmt.Errorf("failed to convert LLM unit cost: %w", err)
158
+ }
159
+ default:
160
+ return out, fmt.Errorf("unknown unit cost type: %s", u.Type)
161
+ }
162
+
163
+ return out, nil
164
+ }
165
+
166
+ func convertUnitCostFromAPI(u *api.BillingFeatureUnitCost) (*feature.UnitCost, error) {
167
+ discriminator, err := u.Discriminator()
168
+ if err != nil {
169
+ return nil, fmt.Errorf("failed to determine unit cost type: %w", err)
170
+ }
171
+
172
+ switch discriminator {
173
+ case "manual":
174
+ manual, err := u.AsBillingFeatureManualUnitCost()
175
+ if err != nil {
176
+ return nil, fmt.Errorf("failed to parse manual unit cost: %w", err)
177
+ }
178
+
179
+ amount, err := alpacadecimal.NewFromString(manual.Amount)
180
+ if err != nil {
181
+ return nil, fmt.Errorf("invalid manual unit cost amount %q: %w", manual.Amount, err)
182
+ }
183
+
184
+ return &feature.UnitCost{
185
+ Type: feature.UnitCostTypeManual,
186
+ Manual: &feature.ManualUnitCost{
187
+ Amount: amount,
188
+ },
189
+ }, nil
190
+ case "llm":
191
+ llm, err := u.AsBillingFeatureLLMUnitCost()
192
+ if err != nil {
193
+ return nil, fmt.Errorf("failed to parse LLM unit cost: %w", err)
194
+ }
195
+
196
+ return &feature.UnitCost{
197
+ Type: feature.UnitCostTypeLLM,
198
+ LLM: &feature.LLMUnitCost{
199
+ ProviderProperty: lo.FromPtrOr(llm.ProviderProperty, ""),
200
+ Provider: lo.FromPtrOr(llm.Provider, ""),
201
+ ModelProperty: lo.FromPtrOr(llm.ModelProperty, ""),
202
+ Model: lo.FromPtrOr(llm.Model, ""),
203
+ TokenTypeProperty: lo.FromPtrOr(llm.TokenTypeProperty, ""),
204
+ TokenType: string(lo.FromPtrOr(llm.TokenType, "")),
205
+ },
206
+ }, nil
207
+ default:
208
+ return nil, fmt.Errorf("unknown unit cost type: %s", discriminator)
209
+ }
210
+ }
211
+
212
+ func enrichFeatureResponseWithPricing(resp *api.Feature, pricing *llmcost.ModelPricing) {
213
+ if resp.UnitCost == nil || pricing == nil {
214
+ return
215
+ }
216
+
217
+ disc, err := resp.UnitCost.Discriminator()
218
+ if err != nil || disc != "llm" {
219
+ return
220
+ }
221
+
222
+ llmCost, err := resp.UnitCost.AsBillingFeatureLLMUnitCost()
223
+ if err != nil {
224
+ return
225
+ }
226
+
227
+ apiPricing := api.BillingFeatureLLMUnitCostPricing{
228
+ InputPerToken: pricing.InputPerToken.String(),
229
+ OutputPerToken: pricing.OutputPerToken.String(),
230
+ }
231
+
232
+ if pricing.CacheReadPerToken != nil {
233
+ v := pricing.CacheReadPerToken.String()
234
+ apiPricing.CacheReadPerToken = &v
235
+ }
236
+
237
+ if pricing.CacheWritePerToken != nil {
238
+ v := pricing.CacheWritePerToken.String()
239
+ apiPricing.CacheWritePerToken = &v
240
+ }
241
+
242
+ if pricing.ReasoningPerToken != nil {
243
+ v := pricing.ReasoningPerToken.String()
244
+ apiPricing.ReasoningPerToken = &v
245
+ }
246
+
247
+ llmCost.Pricing = &apiPricing
248
+ _ = resp.UnitCost.FromBillingFeatureLLMUnitCost(llmCost)
249
+ }
250
+
251
+ // resolveLLMPricing resolves LLM pricing for a feature from the LLM cost database.
252
+ func resolveLLMPricing(ctx context.Context, svc llmcost.Service, feat *feature.Feature) *llmcost.ModelPricing {
253
+ if feat.UnitCost == nil || feat.UnitCost.LLM == nil {
254
+ return nil
255
+ }
256
+
257
+ llmConf := feat.UnitCost.LLM
258
+
259
+ provider := llmConf.Provider
260
+ if provider == "" {
261
+ provider = extractEqFilterValue(feat.MeterGroupByFilters, llmConf.ProviderProperty)
262
+ }
263
+ if provider == "" {
264
+ return nil
265
+ }
266
+
267
+ model := llmConf.Model
268
+ if model == "" {
269
+ model = extractEqFilterValue(feat.MeterGroupByFilters, llmConf.ModelProperty)
270
+ }
271
+ if model == "" {
272
+ return nil
273
+ }
274
+
275
+ price, err := svc.ResolvePrice(ctx, llmcost.ResolvePriceInput{
276
+ Namespace: feat.Namespace,
277
+ Provider: llmcost.Provider(provider),
278
+ ModelID: model,
279
+ })
280
+ if err != nil {
281
+ return nil
282
+ }
283
+
284
+ return &price.Pricing
285
+ }
286
+
287
+ func extractEqFilterValue(filters feature.MeterGroupByFilters, key string) string {
288
+ if filters == nil {
289
+ return ""
290
+ }
291
+
292
+ f, ok := filters[key]
293
+ if !ok || f.Eq == nil {
294
+ return ""
295
+ }
296
+
297
+ return *f.Eq
298
+ }
299
+
300
+ func convertFiltersFromAPI(apiFilters map[string]api.QueryFilterStringMapItem) feature.MeterGroupByFilters {
301
+ result := make(feature.MeterGroupByFilters, len(apiFilters))
302
+ for k, v := range apiFilters {
303
+ result[k] = request.ConvertQueryFilterStringMapItem(v)
304
+ }
305
+ return result
306
+ }
307
+
308
+ func convertFiltersToAPI(filters feature.MeterGroupByFilters) map[string]api.QueryFilterStringMapItem {
309
+ result := make(map[string]api.QueryFilterStringMapItem, len(filters))
310
+ for k, v := range filters {
311
+ result[k] = convertFilterStringToAPIMapItem(v)
312
+ }
313
+ return result
314
+ }
315
+
316
+ func convertFilterStringToAPIMapItem(f filter.FilterString) api.QueryFilterStringMapItem {
317
+ return api.QueryFilterStringMapItem{
318
+ Exists: f.Exists,
319
+ Eq: f.Eq,
320
+ Neq: f.Ne,
321
+ In: f.In,
322
+ Nin: f.Nin,
323
+ Contains: filter.ReverseContainsPattern(f.Like),
324
+ Ncontains: filter.ReverseContainsPattern(f.Nlike),
325
+ And: convertFilterStringListToAPI(f.And),
326
+ Or: convertFilterStringListToAPI(f.Or),
327
+ }
328
+ }
329
+
330
+ func convertFilterStringListToAPI(filters *[]filter.FilterString) *[]api.QueryFilterString {
331
+ if filters == nil {
332
+ return nil
333
+ }
334
+ result := make([]api.QueryFilterString, len(*filters))
335
+ for i, f := range *filters {
336
+ result[i] = convertFilterStringToAPIQueryFilter(f)
337
+ }
338
+ return &result
339
+ }
340
+
341
+ func convertFilterStringToAPIQueryFilter(f filter.FilterString) api.QueryFilterString {
342
+ return api.QueryFilterString{
343
+ Eq: f.Eq,
344
+ Neq: f.Ne,
345
+ In: f.In,
346
+ Nin: f.Nin,
347
+ Contains: filter.ReverseContainsPattern(f.Like),
348
+ Ncontains: filter.ReverseContainsPattern(f.Nlike),
349
+ And: convertFilterStringListToAPI(f.And),
350
+ Or: convertFilterStringListToAPI(f.Or),
351
+ }
352
+ }
api/v3/handlers/features/convert_test.go ADDED
@@ -0,0 +1,574 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package features
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/alpacahq/alpacadecimal"
8
+ "github.com/oapi-codegen/nullable"
9
+ "github.com/samber/lo"
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+
13
+ api "github.com/openmeterio/openmeter/api/v3"
14
+ "github.com/openmeterio/openmeter/api/v3/labels"
15
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
16
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
17
+ "github.com/openmeterio/openmeter/pkg/filter"
18
+ )
19
+
20
+ func TestConvertUnitCostToAPI(t *testing.T) {
21
+ t.Run("manual unit cost", func(t *testing.T) {
22
+ uc := &feature.UnitCost{
23
+ Type: feature.UnitCostTypeManual,
24
+ Manual: &feature.ManualUnitCost{
25
+ Amount: alpacadecimal.NewFromFloat(0.005),
26
+ },
27
+ }
28
+
29
+ result, err := convertUnitCostToAPI(uc)
30
+ require.NoError(t, err)
31
+
32
+ disc, err := result.Discriminator()
33
+ require.NoError(t, err)
34
+ assert.Equal(t, "manual", disc)
35
+
36
+ manual, err := result.AsBillingFeatureManualUnitCost()
37
+ require.NoError(t, err)
38
+ assert.Equal(t, api.Numeric("0.005"), manual.Amount)
39
+ })
40
+
41
+ t.Run("llm unit cost with properties", func(t *testing.T) {
42
+ uc := &feature.UnitCost{
43
+ Type: feature.UnitCostTypeLLM,
44
+ LLM: &feature.LLMUnitCost{
45
+ ProviderProperty: "provider",
46
+ ModelProperty: "model",
47
+ TokenTypeProperty: "type",
48
+ },
49
+ }
50
+
51
+ result, err := convertUnitCostToAPI(uc)
52
+ require.NoError(t, err)
53
+
54
+ disc, err := result.Discriminator()
55
+ require.NoError(t, err)
56
+ assert.Equal(t, "llm", disc)
57
+
58
+ llm, err := result.AsBillingFeatureLLMUnitCost()
59
+ require.NoError(t, err)
60
+ assert.Equal(t, lo.ToPtr("provider"), llm.ProviderProperty)
61
+ assert.Equal(t, lo.ToPtr("model"), llm.ModelProperty)
62
+ assert.Equal(t, lo.ToPtr("type"), llm.TokenTypeProperty)
63
+ assert.Nil(t, llm.Provider)
64
+ assert.Nil(t, llm.Model)
65
+ assert.Nil(t, llm.TokenType)
66
+ })
67
+
68
+ t.Run("llm unit cost with static values", func(t *testing.T) {
69
+ uc := &feature.UnitCost{
70
+ Type: feature.UnitCostTypeLLM,
71
+ LLM: &feature.LLMUnitCost{
72
+ Provider: "openai",
73
+ Model: "gpt-4",
74
+ TokenType: "input",
75
+ },
76
+ }
77
+
78
+ result, err := convertUnitCostToAPI(uc)
79
+ require.NoError(t, err)
80
+
81
+ llm, err := result.AsBillingFeatureLLMUnitCost()
82
+ require.NoError(t, err)
83
+ assert.Equal(t, lo.ToPtr("openai"), llm.Provider)
84
+ assert.Equal(t, lo.ToPtr("gpt-4"), llm.Model)
85
+ assert.Equal(t, lo.ToPtr(api.BillingFeatureLLMTokenTypeInput), llm.TokenType)
86
+ assert.Nil(t, llm.ProviderProperty)
87
+ assert.Nil(t, llm.ModelProperty)
88
+ assert.Nil(t, llm.TokenTypeProperty)
89
+ })
90
+
91
+ t.Run("unknown type returns error", func(t *testing.T) {
92
+ uc := &feature.UnitCost{Type: "unknown"}
93
+ _, err := convertUnitCostToAPI(uc)
94
+ require.Error(t, err)
95
+ assert.Contains(t, err.Error(), "unknown unit cost type")
96
+ })
97
+ }
98
+
99
+ func TestConvertUnitCostFromAPI(t *testing.T) {
100
+ t.Run("manual unit cost", func(t *testing.T) {
101
+ var apiUC api.BillingFeatureUnitCost
102
+ err := apiUC.FromBillingFeatureManualUnitCost(api.BillingFeatureManualUnitCost{
103
+ Amount: "0.123",
104
+ })
105
+ require.NoError(t, err)
106
+
107
+ result, err := convertUnitCostFromAPI(&apiUC)
108
+ require.NoError(t, err)
109
+ assert.Equal(t, feature.UnitCostTypeManual, result.Type)
110
+ assert.NotNil(t, result.Manual)
111
+ assert.Equal(t, "0.123", result.Manual.Amount.String())
112
+ assert.Nil(t, result.LLM)
113
+ })
114
+
115
+ t.Run("llm unit cost with properties", func(t *testing.T) {
116
+ var apiUC api.BillingFeatureUnitCost
117
+ err := apiUC.FromBillingFeatureLLMUnitCost(api.BillingFeatureLLMUnitCost{
118
+ ProviderProperty: lo.ToPtr("provider"),
119
+ ModelProperty: lo.ToPtr("model"),
120
+ TokenTypeProperty: lo.ToPtr("type"),
121
+ })
122
+ require.NoError(t, err)
123
+
124
+ result, err := convertUnitCostFromAPI(&apiUC)
125
+ require.NoError(t, err)
126
+ assert.Equal(t, feature.UnitCostTypeLLM, result.Type)
127
+ assert.NotNil(t, result.LLM)
128
+ assert.Equal(t, "provider", result.LLM.ProviderProperty)
129
+ assert.Equal(t, "model", result.LLM.ModelProperty)
130
+ assert.Equal(t, "type", result.LLM.TokenTypeProperty)
131
+ assert.Empty(t, result.LLM.Provider)
132
+ assert.Empty(t, result.LLM.Model)
133
+ assert.Empty(t, result.LLM.TokenType)
134
+ })
135
+
136
+ t.Run("llm unit cost with static values", func(t *testing.T) {
137
+ var apiUC api.BillingFeatureUnitCost
138
+ err := apiUC.FromBillingFeatureLLMUnitCost(api.BillingFeatureLLMUnitCost{
139
+ Provider: lo.ToPtr("anthropic"),
140
+ Model: lo.ToPtr("claude-3-5-sonnet"),
141
+ TokenType: lo.ToPtr(api.BillingFeatureLLMTokenTypeOutput),
142
+ })
143
+ require.NoError(t, err)
144
+
145
+ result, err := convertUnitCostFromAPI(&apiUC)
146
+ require.NoError(t, err)
147
+ assert.Equal(t, feature.UnitCostTypeLLM, result.Type)
148
+ assert.Equal(t, "anthropic", result.LLM.Provider)
149
+ assert.Equal(t, "claude-3-5-sonnet", result.LLM.Model)
150
+ assert.Equal(t, "output", result.LLM.TokenType)
151
+ })
152
+ }
153
+
154
+ func TestConvertUnitCostRoundTrip(t *testing.T) {
155
+ t.Run("manual round trip", func(t *testing.T) {
156
+ original := &feature.UnitCost{
157
+ Type: feature.UnitCostTypeManual,
158
+ Manual: &feature.ManualUnitCost{
159
+ Amount: alpacadecimal.NewFromFloat(1.50),
160
+ },
161
+ }
162
+
163
+ apiUC, err := convertUnitCostToAPI(original)
164
+ require.NoError(t, err)
165
+
166
+ result, err := convertUnitCostFromAPI(&apiUC)
167
+ require.NoError(t, err)
168
+
169
+ assert.Equal(t, original.Type, result.Type)
170
+ assert.True(t, original.Manual.Amount.Equal(result.Manual.Amount))
171
+ })
172
+
173
+ t.Run("llm round trip", func(t *testing.T) {
174
+ original := &feature.UnitCost{
175
+ Type: feature.UnitCostTypeLLM,
176
+ LLM: &feature.LLMUnitCost{
177
+ ProviderProperty: "provider",
178
+ ModelProperty: "model",
179
+ TokenTypeProperty: "type",
180
+ },
181
+ }
182
+
183
+ apiUC, err := convertUnitCostToAPI(original)
184
+ require.NoError(t, err)
185
+
186
+ result, err := convertUnitCostFromAPI(&apiUC)
187
+ require.NoError(t, err)
188
+
189
+ assert.Equal(t, original.Type, result.Type)
190
+ assert.Equal(t, original.LLM, result.LLM)
191
+ })
192
+ }
193
+
194
+ func TestConvertFeatureToAPI(t *testing.T) {
195
+ now := time.Now().UTC().Truncate(time.Millisecond)
196
+
197
+ t.Run("feature without meter or unit cost", func(t *testing.T) {
198
+ f := feature.Feature{
199
+ Namespace: "default",
200
+ ID: "feat-1",
201
+ Name: "My Feature",
202
+ Key: "my_feature",
203
+ Metadata: map[string]string{"env": "test"},
204
+ CreatedAt: now,
205
+ UpdatedAt: now,
206
+ }
207
+
208
+ result, err := convertFeatureToAPI(f)
209
+ require.NoError(t, err)
210
+ assert.Equal(t, "feat-1", result.Id)
211
+ assert.Equal(t, api.ResourceKey("my_feature"), result.Key)
212
+ assert.Equal(t, "My Feature", result.Name)
213
+ assert.Nil(t, result.Meter)
214
+ assert.Nil(t, result.UnitCost)
215
+ assert.Nil(t, result.DeletedAt)
216
+ require.NotNil(t, result.Labels)
217
+ assert.Equal(t, "test", (*result.Labels)["env"])
218
+ })
219
+
220
+ t.Run("feature with meter and filters", func(t *testing.T) {
221
+ f := feature.Feature{
222
+ Namespace: "default",
223
+ ID: "feat-2",
224
+ Name: "Token Feature",
225
+ Key: "tokens",
226
+ MeterID: lo.ToPtr("01ARZ3NDEKTSV4RRFFQ69G5FAV"),
227
+ MeterGroupByFilters: feature.MeterGroupByFilters{
228
+ "provider": {Eq: lo.ToPtr("openai")},
229
+ },
230
+ CreatedAt: now,
231
+ UpdatedAt: now,
232
+ }
233
+
234
+ result, err := convertFeatureToAPI(f)
235
+ require.NoError(t, err)
236
+ require.NotNil(t, result.Meter)
237
+ assert.Equal(t, api.ULID("01ARZ3NDEKTSV4RRFFQ69G5FAV"), result.Meter.Id)
238
+ require.NotNil(t, result.Meter.Filters)
239
+ filterMap := *result.Meter.Filters
240
+ assert.Equal(t, lo.ToPtr("openai"), filterMap["provider"].Eq)
241
+ })
242
+
243
+ t.Run("feature with manual unit cost", func(t *testing.T) {
244
+ f := feature.Feature{
245
+ Namespace: "default",
246
+ ID: "feat-3",
247
+ Name: "API Calls",
248
+ Key: "api_calls",
249
+ MeterID: lo.ToPtr("api_requests"),
250
+ UnitCost: &feature.UnitCost{
251
+ Type: feature.UnitCostTypeManual,
252
+ Manual: &feature.ManualUnitCost{
253
+ Amount: alpacadecimal.NewFromFloat(0.01),
254
+ },
255
+ },
256
+ CreatedAt: now,
257
+ UpdatedAt: now,
258
+ }
259
+
260
+ result, err := convertFeatureToAPI(f)
261
+ require.NoError(t, err)
262
+ require.NotNil(t, result.UnitCost)
263
+
264
+ disc, err := result.UnitCost.Discriminator()
265
+ require.NoError(t, err)
266
+ assert.Equal(t, "manual", disc)
267
+ })
268
+
269
+ t.Run("feature with archived at", func(t *testing.T) {
270
+ archived := now.Add(-time.Hour)
271
+ f := feature.Feature{
272
+ Namespace: "default",
273
+ ID: "feat-4",
274
+ Name: "Archived",
275
+ Key: "archived",
276
+ ArchivedAt: &archived,
277
+ CreatedAt: now,
278
+ UpdatedAt: now,
279
+ }
280
+
281
+ result, err := convertFeatureToAPI(f)
282
+ require.NoError(t, err)
283
+ require.NotNil(t, result.DeletedAt)
284
+ assert.Equal(t, archived, *result.DeletedAt)
285
+ })
286
+ }
287
+
288
+ func TestConvertCreateRequestToDomain(t *testing.T) {
289
+ t.Run("minimal request without meter", func(t *testing.T) {
290
+ body := api.CreateFeatureRequest{
291
+ Key: "my_key",
292
+ Name: "My Feature",
293
+ }
294
+
295
+ result, err := convertCreateRequestToDomain("test-ns", body, nil)
296
+ require.NoError(t, err)
297
+ assert.Equal(t, "test-ns", result.Namespace)
298
+ assert.Equal(t, "my_key", result.Key)
299
+ assert.Equal(t, "My Feature", result.Name)
300
+ assert.Nil(t, result.MeterID)
301
+ assert.Nil(t, result.UnitCost)
302
+ })
303
+
304
+ t.Run("with meter ID and filters", func(t *testing.T) {
305
+ meterID := "01ARZ3NDEKTSV4RRFFQ69G5FAV"
306
+ body := api.CreateFeatureRequest{
307
+ Key: "tokens",
308
+ Name: "Tokens",
309
+ Meter: &api.FeatureMeterReference{
310
+ Id: api.ULID("01ARZ3NDEKTSV4RRFFQ69G5FAV"),
311
+ Filters: &map[string]api.QueryFilterStringMapItem{
312
+ "model": {Eq: lo.ToPtr("gpt-4")},
313
+ },
314
+ },
315
+ }
316
+
317
+ result, err := convertCreateRequestToDomain("ns", body, &meterID)
318
+ require.NoError(t, err)
319
+ require.NotNil(t, result.MeterID)
320
+ assert.Equal(t, "01ARZ3NDEKTSV4RRFFQ69G5FAV", *result.MeterID)
321
+ require.NotNil(t, result.MeterGroupByFilters)
322
+ assert.Equal(t, lo.ToPtr("gpt-4"), result.MeterGroupByFilters["model"].Eq)
323
+ })
324
+
325
+ t.Run("with manual unit cost", func(t *testing.T) {
326
+ var uc api.BillingFeatureUnitCost
327
+ err := uc.FromBillingFeatureManualUnitCost(api.BillingFeatureManualUnitCost{Amount: "0.05"})
328
+ require.NoError(t, err)
329
+
330
+ body := api.CreateFeatureRequest{
331
+ Key: "feat",
332
+ Name: "Feature",
333
+ UnitCost: &uc,
334
+ }
335
+
336
+ result, err := convertCreateRequestToDomain("ns", body, nil)
337
+ require.NoError(t, err)
338
+ require.NotNil(t, result.UnitCost)
339
+ assert.Equal(t, feature.UnitCostTypeManual, result.UnitCost.Type)
340
+ assert.Equal(t, "0.05", result.UnitCost.Manual.Amount.String())
341
+ })
342
+
343
+ t.Run("with labels", func(t *testing.T) {
344
+ labels := api.Labels{"env": "prod", "team": "billing"}
345
+ body := api.CreateFeatureRequest{
346
+ Key: "feat",
347
+ Name: "Feature",
348
+ Labels: &labels,
349
+ }
350
+
351
+ result, err := convertCreateRequestToDomain("ns", body, nil)
352
+ require.NoError(t, err)
353
+ assert.Equal(t, "prod", result.Metadata["env"])
354
+ assert.Equal(t, "billing", result.Metadata["team"])
355
+ })
356
+ }
357
+
358
+ func TestConvertUpdateRequestToDomain(t *testing.T) {
359
+ t.Run("with unit cost value", func(t *testing.T) {
360
+ var uc api.BillingFeatureUnitCost
361
+ err := uc.FromBillingFeatureManualUnitCost(api.BillingFeatureManualUnitCost{Amount: "0.05"})
362
+ require.NoError(t, err)
363
+
364
+ body := api.UpdateFeatureRequest{UnitCost: nullable.NewNullableWithValue(uc)}
365
+ result, err := convertUpdateRequestToDomain("ns", "feat-1", body)
366
+ require.NoError(t, err)
367
+ assert.Equal(t, "ns", result.Namespace)
368
+ assert.Equal(t, "feat-1", result.ID)
369
+ assert.True(t, result.UnitCost.IsSpecified())
370
+ assert.False(t, result.UnitCost.IsNull())
371
+ unitCost, err := result.UnitCost.Get()
372
+ require.NoError(t, err)
373
+ assert.Equal(t, feature.UnitCostTypeManual, unitCost.Type)
374
+ assert.Equal(t, "0.05", unitCost.Manual.Amount.String())
375
+ })
376
+
377
+ t.Run("with explicit null clears unit cost", func(t *testing.T) {
378
+ body := api.UpdateFeatureRequest{UnitCost: nullable.NewNullNullable[api.BillingFeatureUnitCost]()}
379
+ result, err := convertUpdateRequestToDomain("ns", "feat-1", body)
380
+ require.NoError(t, err)
381
+ assert.True(t, result.UnitCost.IsNull())
382
+ })
383
+
384
+ t.Run("with omitted unit cost", func(t *testing.T) {
385
+ body := api.UpdateFeatureRequest{}
386
+ result, err := convertUpdateRequestToDomain("ns", "feat-1", body)
387
+ require.NoError(t, err)
388
+ assert.False(t, result.UnitCost.IsSpecified())
389
+ })
390
+ }
391
+
392
+ func TestEnrichFeatureResponseWithPricing(t *testing.T) {
393
+ t.Run("adds pricing to llm unit cost", func(t *testing.T) {
394
+ var uc api.BillingFeatureUnitCost
395
+ err := uc.FromBillingFeatureLLMUnitCost(api.BillingFeatureLLMUnitCost{
396
+ ProviderProperty: lo.ToPtr("provider"),
397
+ ModelProperty: lo.ToPtr("model"),
398
+ })
399
+ require.NoError(t, err)
400
+
401
+ resp := &api.Feature{UnitCost: &uc}
402
+ pricing := &llmcost.ModelPricing{
403
+ InputPerToken: alpacadecimal.NewFromFloat(0.00001),
404
+ OutputPerToken: alpacadecimal.NewFromFloat(0.00003),
405
+ }
406
+
407
+ enrichFeatureResponseWithPricing(resp, pricing)
408
+
409
+ llm, err := resp.UnitCost.AsBillingFeatureLLMUnitCost()
410
+ require.NoError(t, err)
411
+ require.NotNil(t, llm.Pricing)
412
+ assert.Equal(t, api.Numeric("0.00001"), llm.Pricing.InputPerToken)
413
+ assert.Equal(t, api.Numeric("0.00003"), llm.Pricing.OutputPerToken)
414
+ assert.Nil(t, llm.Pricing.CacheReadPerToken)
415
+ })
416
+
417
+ t.Run("adds optional pricing fields", func(t *testing.T) {
418
+ var uc api.BillingFeatureUnitCost
419
+ err := uc.FromBillingFeatureLLMUnitCost(api.BillingFeatureLLMUnitCost{
420
+ Provider: lo.ToPtr("openai"),
421
+ Model: lo.ToPtr("gpt-4"),
422
+ })
423
+ require.NoError(t, err)
424
+
425
+ resp := &api.Feature{UnitCost: &uc}
426
+ pricing := &llmcost.ModelPricing{
427
+ InputPerToken: alpacadecimal.NewFromFloat(0.00001),
428
+ OutputPerToken: alpacadecimal.NewFromFloat(0.00003),
429
+ CacheReadPerToken: lo.ToPtr(alpacadecimal.NewFromFloat(0.000005)),
430
+ ReasoningPerToken: lo.ToPtr(alpacadecimal.NewFromFloat(0.00006)),
431
+ }
432
+
433
+ enrichFeatureResponseWithPricing(resp, pricing)
434
+
435
+ llm, err := resp.UnitCost.AsBillingFeatureLLMUnitCost()
436
+ require.NoError(t, err)
437
+ require.NotNil(t, llm.Pricing.CacheReadPerToken)
438
+ assert.Equal(t, api.Numeric("0.000005"), *llm.Pricing.CacheReadPerToken)
439
+ require.NotNil(t, llm.Pricing.ReasoningPerToken)
440
+ assert.Equal(t, api.Numeric("0.00006"), *llm.Pricing.ReasoningPerToken)
441
+ assert.Nil(t, llm.Pricing.CacheWritePerToken)
442
+ })
443
+
444
+ t.Run("no-op when unit cost is manual", func(t *testing.T) {
445
+ var uc api.BillingFeatureUnitCost
446
+ err := uc.FromBillingFeatureManualUnitCost(api.BillingFeatureManualUnitCost{Amount: "0.005"})
447
+ require.NoError(t, err)
448
+ resp := &api.Feature{UnitCost: &uc}
449
+
450
+ enrichFeatureResponseWithPricing(resp, &llmcost.ModelPricing{
451
+ InputPerToken: alpacadecimal.NewFromFloat(0.00001),
452
+ OutputPerToken: alpacadecimal.NewFromFloat(0.00003),
453
+ })
454
+
455
+ // Manual cost should be unchanged
456
+ manual, err := resp.UnitCost.AsBillingFeatureManualUnitCost()
457
+ require.NoError(t, err)
458
+ assert.Equal(t, api.Numeric("0.005"), manual.Amount)
459
+ })
460
+
461
+ t.Run("no-op when unit cost is nil", func(t *testing.T) {
462
+ resp := &api.Feature{}
463
+ enrichFeatureResponseWithPricing(resp, &llmcost.ModelPricing{})
464
+ assert.Nil(t, resp.UnitCost)
465
+ })
466
+
467
+ t.Run("no-op when pricing is nil", func(t *testing.T) {
468
+ var uc api.BillingFeatureUnitCost
469
+ err := uc.FromBillingFeatureLLMUnitCost(api.BillingFeatureLLMUnitCost{})
470
+ require.NoError(t, err)
471
+ resp := &api.Feature{UnitCost: &uc}
472
+ enrichFeatureResponseWithPricing(resp, nil)
473
+
474
+ llm, err := resp.UnitCost.AsBillingFeatureLLMUnitCost()
475
+ require.NoError(t, err)
476
+ assert.Nil(t, llm.Pricing)
477
+ })
478
+ }
479
+
480
+ func TestExtractEqFilterValue(t *testing.T) {
481
+ t.Run("returns value for eq filter", func(t *testing.T) {
482
+ filters := feature.MeterGroupByFilters{
483
+ "provider": {Eq: lo.ToPtr("openai")},
484
+ }
485
+ assert.Equal(t, "openai", extractEqFilterValue(filters, "provider"))
486
+ })
487
+
488
+ t.Run("returns empty for missing key", func(t *testing.T) {
489
+ filters := feature.MeterGroupByFilters{
490
+ "provider": {Eq: lo.ToPtr("openai")},
491
+ }
492
+ assert.Equal(t, "", extractEqFilterValue(filters, "model"))
493
+ })
494
+
495
+ t.Run("returns empty for non-eq filter", func(t *testing.T) {
496
+ filters := feature.MeterGroupByFilters{
497
+ "provider": {In: &[]string{"openai", "anthropic"}},
498
+ }
499
+ assert.Equal(t, "", extractEqFilterValue(filters, "provider"))
500
+ })
501
+
502
+ t.Run("returns empty for nil filters", func(t *testing.T) {
503
+ assert.Equal(t, "", extractEqFilterValue(nil, "key"))
504
+ })
505
+ }
506
+
507
+ func TestConvertFiltersRoundTrip(t *testing.T) {
508
+ original := feature.MeterGroupByFilters{
509
+ "provider": {Eq: lo.ToPtr("openai")},
510
+ "model": {In: &[]string{"gpt-4", "gpt-4o"}},
511
+ }
512
+
513
+ apiFilters := convertFiltersToAPI(original)
514
+ assert.Len(t, apiFilters, 2)
515
+ assert.Equal(t, lo.ToPtr("openai"), apiFilters["provider"].Eq)
516
+ assert.Equal(t, &[]string{"gpt-4", "gpt-4o"}, apiFilters["model"].In)
517
+
518
+ roundTripped := convertFiltersFromAPI(apiFilters)
519
+ assert.Equal(t, lo.ToPtr("openai"), roundTripped["provider"].Eq)
520
+ assert.Equal(t, &[]string{"gpt-4", "gpt-4o"}, roundTripped["model"].In)
521
+ }
522
+
523
+ func TestConvertMetadataLabels(t *testing.T) {
524
+ t.Run("metadata to labels", func(t *testing.T) {
525
+ l := labels.FromMetadata(map[string]string{"a": "1", "b": "2"})
526
+ require.NotNil(t, l)
527
+ assert.Equal(t, "1", (*l)["a"])
528
+ assert.Equal(t, "2", (*l)["b"])
529
+ })
530
+
531
+ t.Run("nil metadata returns nil labels", func(t *testing.T) {
532
+ l := labels.FromMetadata((map[string]string)(nil))
533
+ assert.Empty(t, l)
534
+ })
535
+
536
+ t.Run("labels to metadata", func(t *testing.T) {
537
+ l := api.Labels{"x": "y"}
538
+ meta, err := labels.ToMetadata(&l)
539
+ assert.NoError(t, err)
540
+ assert.Equal(t, "y", meta["x"])
541
+ })
542
+
543
+ t.Run("nil labels returns nil metadata", func(t *testing.T) {
544
+ meta, err := labels.ToMetadata(nil)
545
+ assert.NoError(t, err)
546
+ assert.Nil(t, meta)
547
+ })
548
+ }
549
+
550
+ func TestConvertFilterStringToAPIMapItem(t *testing.T) {
551
+ t.Run("eq filter", func(t *testing.T) {
552
+ f := filter.FilterString{Eq: lo.ToPtr("val")}
553
+ result := convertFilterStringToAPIMapItem(f)
554
+ assert.Equal(t, lo.ToPtr("val"), result.Eq)
555
+ })
556
+
557
+ t.Run("in filter", func(t *testing.T) {
558
+ f := filter.FilterString{In: &[]string{"a", "b"}}
559
+ result := convertFilterStringToAPIMapItem(f)
560
+ assert.Equal(t, &[]string{"a", "b"}, result.In)
561
+ })
562
+
563
+ t.Run("ne filter", func(t *testing.T) {
564
+ f := filter.FilterString{Ne: lo.ToPtr("excluded")}
565
+ result := convertFilterStringToAPIMapItem(f)
566
+ assert.Equal(t, lo.ToPtr("excluded"), result.Neq)
567
+ })
568
+
569
+ t.Run("exists filter", func(t *testing.T) {
570
+ f := filter.FilterString{Exists: lo.ToPtr(true)}
571
+ result := convertFilterStringToAPIMapItem(f)
572
+ assert.Equal(t, lo.ToPtr(true), result.Exists)
573
+ })
574
+ }
api/v3/handlers/features/create.go ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+
8
+ api "github.com/openmeterio/openmeter/api/v3"
9
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
10
+ "github.com/openmeterio/openmeter/api/v3/request"
11
+ "github.com/openmeterio/openmeter/openmeter/meter"
12
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
13
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
14
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
15
+ "github.com/openmeterio/openmeter/pkg/models"
16
+ )
17
+
18
+ type (
19
+ CreateFeatureRequest = feature.CreateFeatureInputs
20
+ CreateFeatureResponse = api.Feature
21
+ CreateFeatureHandler httptransport.Handler[CreateFeatureRequest, CreateFeatureResponse]
22
+ )
23
+
24
+ func (h *handler) CreateFeature() CreateFeatureHandler {
25
+ return httptransport.NewHandler(
26
+ func(ctx context.Context, r *http.Request) (CreateFeatureRequest, error) {
27
+ body := api.CreateFeatureRequest{}
28
+ if err := request.ParseBody(r, &body); err != nil {
29
+ return CreateFeatureRequest{}, err
30
+ }
31
+
32
+ ns, err := h.resolveNamespace(ctx)
33
+ if err != nil {
34
+ return CreateFeatureRequest{}, err
35
+ }
36
+
37
+ // Resolve and validate the meter reference.
38
+ var meterID *string
39
+ if body.Meter != nil {
40
+ if body.Meter.Id == "" {
41
+ return CreateFeatureRequest{}, models.NewGenericValidationError(
42
+ fmt.Errorf("meter id is required"),
43
+ )
44
+ }
45
+
46
+ m, err := h.meterService.GetMeterByIDOrSlug(ctx, meter.GetMeterInput{
47
+ Namespace: ns,
48
+ IDOrSlug: body.Meter.Id,
49
+ })
50
+ if err != nil {
51
+ return CreateFeatureRequest{}, err
52
+ }
53
+ meterID = &m.ID
54
+
55
+ // Validate meter filters.
56
+ if body.Meter.Filters != nil {
57
+ if err := validateMeterFilters(*body.Meter.Filters, m); err != nil {
58
+ return CreateFeatureRequest{}, err
59
+ }
60
+ }
61
+ }
62
+
63
+ return convertCreateRequestToDomain(ns, body, meterID)
64
+ },
65
+ func(ctx context.Context, req CreateFeatureRequest) (CreateFeatureResponse, error) {
66
+ created, err := h.connector.CreateFeature(ctx, req)
67
+ if err != nil {
68
+ return CreateFeatureResponse{}, err
69
+ }
70
+
71
+ return convertFeatureToAPI(created)
72
+ },
73
+ commonhttp.JSONResponseEncoderWithStatus[CreateFeatureResponse](http.StatusCreated),
74
+ httptransport.AppendOptions(
75
+ h.options,
76
+ httptransport.WithOperationName("create-feature"),
77
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
78
+ httptransport.WithErrorEncoder(errorEncoder()),
79
+ )...,
80
+ )
81
+ }
82
+
83
+ // validateMeterFilters validates that filter keys exist in the meter's dimensions.
84
+ // The single-operator invariant on each filter value is enforced downstream by
85
+ // feature.MeterGroupByFilters.Validate.
86
+ func validateMeterFilters(filters map[string]api.QueryFilterStringMapItem, m meter.Meter) error {
87
+ for k := range filters {
88
+ if _, ok := m.GroupBy[k]; !ok {
89
+ return models.NewGenericValidationError(
90
+ fmt.Errorf("filter key %q is not a valid dimension of meter %q", k, m.Key),
91
+ )
92
+ }
93
+ }
94
+ return nil
95
+ }
api/v3/handlers/features/create_test.go ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package features
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/samber/lo"
7
+ "github.com/stretchr/testify/assert"
8
+ "github.com/stretchr/testify/require"
9
+
10
+ api "github.com/openmeterio/openmeter/api/v3"
11
+ "github.com/openmeterio/openmeter/openmeter/meter"
12
+ )
13
+
14
+ func TestValidateMeterFilters(t *testing.T) {
15
+ testMeter := meter.Meter{
16
+ Key: "tokens_total",
17
+ GroupBy: map[string]string{
18
+ "provider": "$.provider",
19
+ "model": "$.model",
20
+ "type": "$.type",
21
+ },
22
+ }
23
+
24
+ t.Run("valid filters", func(t *testing.T) {
25
+ filters := map[string]api.QueryFilterStringMapItem{
26
+ "provider": {Eq: lo.ToPtr("openai")},
27
+ "model": {In: &[]string{"gpt-4", "gpt-4o"}},
28
+ }
29
+
30
+ err := validateMeterFilters(filters, testMeter)
31
+ require.NoError(t, err)
32
+ })
33
+
34
+ t.Run("invalid dimension key", func(t *testing.T) {
35
+ filters := map[string]api.QueryFilterStringMapItem{
36
+ "nonexistent": {Eq: lo.ToPtr("value")},
37
+ }
38
+
39
+ err := validateMeterFilters(filters, testMeter)
40
+ require.Error(t, err)
41
+ assert.Contains(t, err.Error(), "nonexistent")
42
+ assert.Contains(t, err.Error(), "not a valid dimension")
43
+ })
44
+
45
+ t.Run("empty filters", func(t *testing.T) {
46
+ err := validateMeterFilters(map[string]api.QueryFilterStringMapItem{}, testMeter)
47
+ require.NoError(t, err)
48
+ })
49
+
50
+ t.Run("mix of valid and invalid keys", func(t *testing.T) {
51
+ filters := map[string]api.QueryFilterStringMapItem{
52
+ "provider": {Eq: lo.ToPtr("openai")},
53
+ "bad_key": {Eq: lo.ToPtr("value")},
54
+ }
55
+
56
+ err := validateMeterFilters(filters, testMeter)
57
+ require.Error(t, err)
58
+ assert.Contains(t, err.Error(), "bad_key")
59
+ })
60
+ }
api/v3/handlers/features/delete.go ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
8
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
9
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
10
+ "github.com/openmeterio/openmeter/pkg/models"
11
+ )
12
+
13
+ type (
14
+ DeleteFeatureRequest = models.NamespacedID
15
+ DeleteFeatureResponse = interface{}
16
+ DeleteFeatureParams = string
17
+ DeleteFeatureHandler httptransport.HandlerWithArgs[DeleteFeatureRequest, DeleteFeatureResponse, DeleteFeatureParams]
18
+ )
19
+
20
+ func (h *handler) DeleteFeature() DeleteFeatureHandler {
21
+ return httptransport.NewHandlerWithArgs(
22
+ func(ctx context.Context, r *http.Request, featureID DeleteFeatureParams) (DeleteFeatureRequest, error) {
23
+ ns, err := h.resolveNamespace(ctx)
24
+ if err != nil {
25
+ return DeleteFeatureRequest{}, err
26
+ }
27
+
28
+ return DeleteFeatureRequest{
29
+ Namespace: ns,
30
+ ID: featureID,
31
+ }, nil
32
+ },
33
+ func(ctx context.Context, req DeleteFeatureRequest) (DeleteFeatureResponse, error) {
34
+ err := h.connector.ArchiveFeature(ctx, req)
35
+ if err != nil {
36
+ return nil, err
37
+ }
38
+
39
+ return nil, nil
40
+ },
41
+ commonhttp.EmptyResponseEncoder[DeleteFeatureResponse](http.StatusNoContent),
42
+ httptransport.AppendOptions(
43
+ h.options,
44
+ httptransport.WithOperationName("delete-feature"),
45
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
46
+ httptransport.WithErrorEncoder(errorEncoder()),
47
+ )...,
48
+ )
49
+ }
api/v3/handlers/features/error_encoder.go ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
8
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
9
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport/encoder"
10
+ )
11
+
12
+ func errorEncoder() encoder.ErrorEncoder {
13
+ return func(ctx context.Context, err error, w http.ResponseWriter, r *http.Request) bool {
14
+ return commonhttp.HandleErrorIfTypeMatches[*feature.FeatureInvalidFiltersError](ctx, http.StatusBadRequest, err, w) ||
15
+ commonhttp.HandleErrorIfTypeMatches[*feature.FeatureInvalidMeterAggregationError](ctx, http.StatusBadRequest, err, w) ||
16
+ commonhttp.HandleErrorIfTypeMatches[*feature.ForbiddenError](ctx, http.StatusForbidden, err, w) ||
17
+ commonhttp.HandleErrorIfTypeMatches[*feature.FeatureWithNameAlreadyExistsError](ctx, http.StatusConflict, err, w)
18
+ }
19
+ }
api/v3/handlers/features/get.go ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ api "github.com/openmeterio/openmeter/api/v3"
8
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
9
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
10
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
11
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
12
+ )
13
+
14
+ type (
15
+ GetFeatureRequest struct {
16
+ Namespace string
17
+ IDOrKey string
18
+ }
19
+ GetFeatureResponse = api.Feature
20
+ GetFeatureParams = string
21
+ GetFeatureHandler httptransport.HandlerWithArgs[GetFeatureRequest, GetFeatureResponse, GetFeatureParams]
22
+ )
23
+
24
+ func (h *handler) GetFeature() GetFeatureHandler {
25
+ return httptransport.NewHandlerWithArgs(
26
+ func(ctx context.Context, r *http.Request, featureID GetFeatureParams) (GetFeatureRequest, error) {
27
+ ns, err := h.resolveNamespace(ctx)
28
+ if err != nil {
29
+ return GetFeatureRequest{}, err
30
+ }
31
+
32
+ return GetFeatureRequest{
33
+ Namespace: ns,
34
+ IDOrKey: featureID,
35
+ }, nil
36
+ },
37
+ func(ctx context.Context, req GetFeatureRequest) (GetFeatureResponse, error) {
38
+ feat, err := h.connector.GetFeature(ctx, req.Namespace, req.IDOrKey, feature.IncludeArchivedFeatureFalse)
39
+ if err != nil {
40
+ return GetFeatureResponse{}, err
41
+ }
42
+
43
+ resp, err := convertFeatureToAPI(*feat)
44
+ if err != nil {
45
+ return GetFeatureResponse{}, err
46
+ }
47
+
48
+ // Resolve LLM pricing if applicable
49
+ if feat.UnitCost != nil && feat.UnitCost.Type == feature.UnitCostTypeLLM && h.llmcostService != nil {
50
+ pricing := resolveLLMPricing(ctx, h.llmcostService, feat)
51
+ if pricing != nil {
52
+ enrichFeatureResponseWithPricing(&resp, pricing)
53
+ }
54
+ }
55
+
56
+ return resp, nil
57
+ },
58
+ commonhttp.JSONResponseEncoderWithStatus[GetFeatureResponse](http.StatusOK),
59
+ httptransport.AppendOptions(
60
+ h.options,
61
+ httptransport.WithOperationName("get-feature"),
62
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
63
+ httptransport.WithErrorEncoder(errorEncoder()),
64
+ )...,
65
+ )
66
+ }
api/v3/handlers/features/handler.go ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
7
+ "github.com/openmeterio/openmeter/openmeter/meter"
8
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
9
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
10
+ )
11
+
12
+ type Handler interface {
13
+ ListFeatures() ListFeaturesHandler
14
+ GetFeature() GetFeatureHandler
15
+ CreateFeature() CreateFeatureHandler
16
+ UpdateFeature() UpdateFeatureHandler
17
+ DeleteFeature() DeleteFeatureHandler
18
+ }
19
+
20
+ type handler struct {
21
+ resolveNamespace func(ctx context.Context) (string, error)
22
+ connector feature.FeatureConnector
23
+ meterService meter.Service
24
+ llmcostService llmcost.Service
25
+ options []httptransport.HandlerOption
26
+ }
27
+
28
+ func New(
29
+ resolveNamespace func(ctx context.Context) (string, error),
30
+ connector feature.FeatureConnector,
31
+ meterService meter.Service,
32
+ llmcostService llmcost.Service,
33
+ options ...httptransport.HandlerOption,
34
+ ) Handler {
35
+ return &handler{
36
+ resolveNamespace: resolveNamespace,
37
+ connector: connector,
38
+ meterService: meterService,
39
+ llmcostService: llmcostService,
40
+ options: options,
41
+ }
42
+ }
api/v3/handlers/features/list.go ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ "github.com/samber/lo"
8
+
9
+ api "github.com/openmeterio/openmeter/api/v3"
10
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
11
+ "github.com/openmeterio/openmeter/api/v3/filters"
12
+ "github.com/openmeterio/openmeter/api/v3/request"
13
+ "github.com/openmeterio/openmeter/api/v3/response"
14
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
15
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
16
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
17
+ "github.com/openmeterio/openmeter/pkg/pagination"
18
+ )
19
+
20
+ type (
21
+ ListFeaturesRequest = feature.ListFeaturesParams
22
+ ListFeaturesResponse = response.PagePaginationResponse[api.Feature]
23
+ ListFeaturesParams = api.ListFeaturesParams
24
+ ListFeaturesHandler httptransport.HandlerWithArgs[ListFeaturesRequest, ListFeaturesResponse, ListFeaturesParams]
25
+ )
26
+
27
+ func (h *handler) ListFeatures() ListFeaturesHandler {
28
+ return httptransport.NewHandlerWithArgs(
29
+ func(ctx context.Context, r *http.Request, params ListFeaturesParams) (ListFeaturesRequest, error) {
30
+ ns, err := h.resolveNamespace(ctx)
31
+ if err != nil {
32
+ return ListFeaturesRequest{}, err
33
+ }
34
+
35
+ page := pagination.NewPage(1, 20)
36
+ if params.Page != nil {
37
+ page = pagination.NewPage(
38
+ lo.FromPtrOr(params.Page.Number, 1),
39
+ lo.FromPtrOr(params.Page.Size, 20),
40
+ )
41
+ }
42
+
43
+ if err := page.Validate(); err != nil {
44
+ return ListFeaturesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
45
+ apierrors.InvalidParameter{
46
+ Field: "page",
47
+ Reason: err.Error(),
48
+ Source: apierrors.InvalidParamSourceQuery,
49
+ },
50
+ })
51
+ }
52
+
53
+ req := ListFeaturesRequest{
54
+ Namespace: ns,
55
+ Page: page,
56
+ }
57
+
58
+ if params.Filter != nil {
59
+ meterIDs, err := filters.FromAPIFilterULID(params.Filter.MeterId)
60
+ if err != nil {
61
+ return ListFeaturesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
62
+ {Field: "filter[meter_id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
63
+ })
64
+ }
65
+ req.MeterIDs = meterIDs
66
+
67
+ key, err := filters.FromAPIFilterString(params.Filter.Key)
68
+ if err != nil {
69
+ return ListFeaturesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
70
+ {Field: "filter[key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
71
+ })
72
+ }
73
+ req.Key = key
74
+
75
+ name, err := filters.FromAPIFilterString(params.Filter.Name)
76
+ if err != nil {
77
+ return ListFeaturesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
78
+ {Field: "filter[name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
79
+ })
80
+ }
81
+ req.Name = name
82
+ }
83
+
84
+ if params.Sort != nil {
85
+ sort, err := request.ParseSortBy(*params.Sort)
86
+ if err != nil {
87
+ return ListFeaturesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
88
+ {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
89
+ })
90
+ }
91
+
92
+ orderBy, err := FromAPIFeatureSortField(ctx, sort.Field)
93
+ if err != nil {
94
+ return ListFeaturesRequest{}, err
95
+ }
96
+ req.OrderBy = orderBy
97
+ req.Order = sort.Order.ToSortxOrder()
98
+ }
99
+
100
+ return req, nil
101
+ },
102
+ func(ctx context.Context, req ListFeaturesRequest) (ListFeaturesResponse, error) {
103
+ result, err := h.connector.ListFeatures(ctx, req)
104
+ if err != nil {
105
+ return ListFeaturesResponse{}, err
106
+ }
107
+
108
+ items := make([]api.Feature, 0, len(result.Items))
109
+ for _, f := range result.Items {
110
+ apiFeature, err := convertFeatureToAPI(f)
111
+ if err != nil {
112
+ return ListFeaturesResponse{}, err
113
+ }
114
+ items = append(items, apiFeature)
115
+ }
116
+
117
+ return response.NewPagePaginationResponse(items, response.PageMetaPage{
118
+ Size: req.Page.PageSize,
119
+ Number: req.Page.PageNumber,
120
+ Total: lo.ToPtr(result.TotalCount),
121
+ }), nil
122
+ },
123
+ commonhttp.JSONResponseEncoderWithStatus[ListFeaturesResponse](http.StatusOK),
124
+ httptransport.AppendOptions(
125
+ h.options,
126
+ httptransport.WithOperationName("list-features"),
127
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
128
+ httptransport.WithErrorEncoder(errorEncoder()),
129
+ )...,
130
+ )
131
+ }
api/v3/handlers/features/update.go ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ api "github.com/openmeterio/openmeter/api/v3"
8
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
9
+ "github.com/openmeterio/openmeter/api/v3/request"
10
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
11
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
12
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
13
+ )
14
+
15
+ type (
16
+ UpdateFeatureRequest = feature.UpdateFeatureInputs
17
+ UpdateFeatureResponse = api.Feature
18
+ UpdateFeatureParams = string
19
+ UpdateFeatureHandler httptransport.HandlerWithArgs[UpdateFeatureRequest, UpdateFeatureResponse, UpdateFeatureParams]
20
+ )
21
+
22
+ func (h *handler) UpdateFeature() UpdateFeatureHandler {
23
+ return httptransport.NewHandlerWithArgs(
24
+ func(ctx context.Context, r *http.Request, featureID UpdateFeatureParams) (UpdateFeatureRequest, error) {
25
+ body := api.UpdateFeatureRequest{}
26
+ if err := request.ParseBody(r, &body); err != nil {
27
+ return UpdateFeatureRequest{}, err
28
+ }
29
+
30
+ ns, err := h.resolveNamespace(ctx)
31
+ if err != nil {
32
+ return UpdateFeatureRequest{}, err
33
+ }
34
+
35
+ return convertUpdateRequestToDomain(ns, featureID, body)
36
+ },
37
+ func(ctx context.Context, req UpdateFeatureRequest) (UpdateFeatureResponse, error) {
38
+ updated, err := h.connector.UpdateFeature(ctx, req)
39
+ if err != nil {
40
+ return UpdateFeatureResponse{}, err
41
+ }
42
+
43
+ resp, err := convertFeatureToAPI(updated)
44
+ if err != nil {
45
+ return UpdateFeatureResponse{}, err
46
+ }
47
+
48
+ // Resolve LLM pricing if applicable
49
+ if updated.UnitCost != nil && updated.UnitCost.Type == feature.UnitCostTypeLLM && h.llmcostService != nil {
50
+ pricing := resolveLLMPricing(ctx, h.llmcostService, &updated)
51
+ if pricing != nil {
52
+ enrichFeatureResponseWithPricing(&resp, pricing)
53
+ }
54
+ }
55
+
56
+ return resp, nil
57
+ },
58
+ commonhttp.JSONResponseEncoderWithStatus[UpdateFeatureResponse](http.StatusOK),
59
+ httptransport.AppendOptions(
60
+ h.options,
61
+ httptransport.WithOperationName("update-feature"),
62
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
63
+ httptransport.WithErrorEncoder(errorEncoder()),
64
+ )...,
65
+ )
66
+ }
api/v3/handlers/governance/handler.go ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package governance
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/governance"
7
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
8
+ )
9
+
10
+ type Handler interface {
11
+ QueryGovernanceAccess() QueryGovernanceAccessHandler
12
+ }
13
+
14
+ type handler struct {
15
+ resolveNamespace func(ctx context.Context) (string, error)
16
+ governanceService governance.Service
17
+ options []httptransport.HandlerOption
18
+ }
19
+
20
+ func New(
21
+ resolveNamespace func(ctx context.Context) (string, error),
22
+ governanceService governance.Service,
23
+ options ...httptransport.HandlerOption,
24
+ ) Handler {
25
+ return &handler{
26
+ resolveNamespace: resolveNamespace,
27
+ governanceService: governanceService,
28
+ options: options,
29
+ }
30
+ }
api/v3/handlers/governance/mapping.go ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package governance
2
+
3
+ import (
4
+ "github.com/oapi-codegen/nullable"
5
+ "github.com/samber/lo"
6
+
7
+ apiv3 "github.com/openmeterio/openmeter/api/v3"
8
+ customershandler "github.com/openmeterio/openmeter/api/v3/handlers/customers"
9
+ "github.com/openmeterio/openmeter/openmeter/governance"
10
+ )
11
+
12
+ // ToAPIGovernanceQueryResponse maps a domain QueryResult to the API response.
13
+ func ToAPIGovernanceQueryResponse(res governance.QueryResult, pageSize int) apiv3.GovernanceQueryResponse {
14
+ data := make([]apiv3.GovernanceQueryResult, 0, len(res.Customers))
15
+
16
+ for _, c := range res.Customers {
17
+ features := make(map[string]apiv3.GovernanceFeatureAccess, len(c.Features))
18
+ for key, fa := range c.Features {
19
+ features[key] = toAPIFeatureAccess(fa)
20
+ }
21
+
22
+ data = append(data, apiv3.GovernanceQueryResult{
23
+ Matched: c.Matched,
24
+ Customer: customershandler.ToAPIBillingCustomer(c.Customer),
25
+ Features: features,
26
+ UpdatedAt: c.UpdatedAt,
27
+ })
28
+ }
29
+
30
+ errs := make([]apiv3.GovernanceQueryError, 0, len(res.Errors))
31
+
32
+ for _, e := range res.Errors {
33
+ errs = append(errs, apiv3.GovernanceQueryError{
34
+ Customer: lo.ToPtr(e.CustomerKey),
35
+ Code: toAPIQueryErrorCode(e.Code),
36
+ Message: e.Message,
37
+ })
38
+ }
39
+
40
+ return apiv3.GovernanceQueryResponse{
41
+ Data: data,
42
+ Errors: errs,
43
+ Meta: toAPICursorMeta(res, pageSize),
44
+ }
45
+ }
46
+
47
+ func toAPIFeatureAccess(fa governance.FeatureAccess) apiv3.GovernanceFeatureAccess {
48
+ out := apiv3.GovernanceFeatureAccess{HasAccess: fa.HasAccess}
49
+
50
+ if fa.Reason != nil {
51
+ out.Reason = &apiv3.GovernanceFeatureAccessReason{
52
+ Code: toAPIReasonCode(fa.Reason.Code),
53
+ Message: fa.Reason.Message,
54
+ }
55
+ }
56
+
57
+ return out
58
+ }
59
+
60
+ func toAPIReasonCode(code governance.ReasonCode) apiv3.GovernanceFeatureAccessReasonCode {
61
+ switch code {
62
+ case governance.ReasonCodeUsageLimitReached:
63
+ return apiv3.GovernanceFeatureAccessReasonCodeUsageLimitReached
64
+ case governance.ReasonCodeFeatureUnavailable:
65
+ return apiv3.GovernanceFeatureAccessReasonCodeFeatureUnavailable
66
+ case governance.ReasonCodeFeatureNotFound:
67
+ return apiv3.GovernanceFeatureAccessReasonCodeFeatureNotFound
68
+ case governance.ReasonCodeNoCreditAvailable:
69
+ return apiv3.GovernanceFeatureAccessReasonCodeNoCreditAvailable
70
+ default:
71
+ return apiv3.GovernanceFeatureAccessReasonCodeUnknown
72
+ }
73
+ }
74
+
75
+ func toAPIQueryErrorCode(code governance.QueryErrorCode) apiv3.GovernanceQueryErrorCode {
76
+ switch code {
77
+ case governance.QueryErrorCustomerNotFound:
78
+ return apiv3.GovernanceQueryErrorCodeCustomerNotFound
79
+ default:
80
+ return apiv3.GovernanceQueryErrorCodeUnknown
81
+ }
82
+ }
83
+
84
+ // toAPICursorMeta builds cursor pagination metadata from the domain result.
85
+ func toAPICursorMeta(res governance.QueryResult, pageSize int) apiv3.CursorMeta {
86
+ meta := apiv3.CursorMeta{
87
+ Page: apiv3.CursorMetaPage{
88
+ Next: nullable.NewNullNullable[string](),
89
+ Previous: nullable.NewNullNullable[string](),
90
+ Size: float32(pageSize),
91
+ },
92
+ }
93
+
94
+ if res.First != nil {
95
+ meta.Page.First = lo.ToPtr(res.First.Encode())
96
+ if res.HasPrev {
97
+ meta.Page.Previous = nullable.NewNullableWithValue(res.First.Encode())
98
+ }
99
+ }
100
+
101
+ if res.Last != nil {
102
+ meta.Page.Last = lo.ToPtr(res.Last.Encode())
103
+ if res.HasNext {
104
+ meta.Page.Next = nullable.NewNullableWithValue(res.Last.Encode())
105
+ }
106
+ }
107
+
108
+ return meta
109
+ }
api/v3/handlers/governance/query.go ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package governance
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+
8
+ apiv3 "github.com/openmeterio/openmeter/api/v3"
9
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
10
+ "github.com/openmeterio/openmeter/openmeter/governance"
11
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
12
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
13
+ pagination "github.com/openmeterio/openmeter/pkg/pagination/v2"
14
+ )
15
+
16
+ const (
17
+ defaultPageSize = 100
18
+ maxPageSize = 100
19
+ )
20
+
21
+ type (
22
+ QueryGovernanceAccessParams = apiv3.QueryGovernanceAccessParams
23
+ QueryGovernanceAccessRequest = governance.QueryAccessInput
24
+ QueryGovernanceAccessResponse = apiv3.GovernanceQueryResponse
25
+ QueryGovernanceAccessHandler = httptransport.HandlerWithArgs[QueryGovernanceAccessRequest, QueryGovernanceAccessResponse, QueryGovernanceAccessParams]
26
+ )
27
+
28
+ func (h *handler) QueryGovernanceAccess() QueryGovernanceAccessHandler {
29
+ return httptransport.NewHandlerWithArgs(
30
+ func(ctx context.Context, r *http.Request, params QueryGovernanceAccessParams) (QueryGovernanceAccessRequest, error) {
31
+ ns, err := h.resolveNamespace(ctx)
32
+ if err != nil {
33
+ return QueryGovernanceAccessRequest{}, err
34
+ }
35
+
36
+ var body apiv3.GovernanceQueryRequest
37
+
38
+ if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil {
39
+ return QueryGovernanceAccessRequest{}, err
40
+ }
41
+
42
+ req := QueryGovernanceAccessRequest{
43
+ Namespace: ns,
44
+ CustomerKeys: body.Customer.Keys,
45
+ PageSize: defaultPageSize,
46
+ }
47
+
48
+ if body.Feature != nil {
49
+ req.FeatureKeys = body.Feature.Keys
50
+ }
51
+
52
+ if body.IncludeCredits != nil {
53
+ req.IncludeCredits = *body.IncludeCredits
54
+ }
55
+
56
+ if err := applyPaging(ctx, &req, params); err != nil {
57
+ return QueryGovernanceAccessRequest{}, err
58
+ }
59
+
60
+ return req, nil
61
+ },
62
+ func(ctx context.Context, request QueryGovernanceAccessRequest) (QueryGovernanceAccessResponse, error) {
63
+ res, err := h.governanceService.QueryAccess(ctx, request)
64
+ if err != nil {
65
+ return QueryGovernanceAccessResponse{}, err
66
+ }
67
+
68
+ return ToAPIGovernanceQueryResponse(res, request.PageSize), nil
69
+ },
70
+ commonhttp.JSONResponseEncoderWithStatus[QueryGovernanceAccessResponse](http.StatusOK),
71
+ httptransport.AppendOptions(
72
+ h.options,
73
+ httptransport.WithOperationName("query-governance-access"),
74
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
75
+ )...,
76
+ )
77
+ }
78
+
79
+ // applyPaging parses page[size]/page[after]/page[before] into the service input.
80
+ func applyPaging(ctx context.Context, req *QueryGovernanceAccessRequest, params QueryGovernanceAccessParams) error {
81
+ if params.Page == nil {
82
+ return nil
83
+ }
84
+
85
+ if params.Page.Size != nil {
86
+ if *params.Page.Size < 1 || *params.Page.Size > maxPageSize {
87
+ return apierrors.NewBadRequestError(ctx,
88
+ fmt.Errorf("page[size] must be between 1 and %d", maxPageSize),
89
+ apierrors.InvalidParameters{{
90
+ Field: "page[size]",
91
+ Reason: fmt.Sprintf("must be between 1 and %d", maxPageSize),
92
+ Source: apierrors.InvalidParamSourceQuery,
93
+ }},
94
+ )
95
+ }
96
+
97
+ req.PageSize = *params.Page.Size
98
+ }
99
+
100
+ if params.Page.After != nil && params.Page.Before != nil {
101
+ return apierrors.NewBadRequestError(ctx,
102
+ fmt.Errorf("page[after] and page[before] are mutually exclusive"),
103
+ apierrors.InvalidParameters{{
104
+ Field: "page[after]",
105
+ Reason: "cannot be combined with page[before]",
106
+ Source: apierrors.InvalidParamSourceQuery,
107
+ }},
108
+ )
109
+ }
110
+
111
+ if params.Page.After != nil {
112
+ cursor, err := decodeCursorParam(ctx, "page[after]", *params.Page.After)
113
+ if err != nil {
114
+ return err
115
+ }
116
+ req.After = cursor
117
+ }
118
+
119
+ if params.Page.Before != nil {
120
+ cursor, err := decodeCursorParam(ctx, "page[before]", *params.Page.Before)
121
+ if err != nil {
122
+ return err
123
+ }
124
+ req.Before = cursor
125
+ }
126
+
127
+ return nil
128
+ }
129
+
130
+ func decodeCursorParam(ctx context.Context, field, raw string) (*pagination.Cursor, error) {
131
+ cursor, err := pagination.DecodeCursor(raw)
132
+ if err != nil {
133
+ return nil, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{{
134
+ Field: field,
135
+ Reason: err.Error(),
136
+ Source: apierrors.InvalidParamSourceQuery,
137
+ }})
138
+ }
139
+
140
+ return cursor, nil
141
+ }
api/v3/handlers/llmcost/convert.go ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package llmcost
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "strings"
7
+
8
+ "github.com/alpacahq/alpacadecimal"
9
+ "github.com/samber/lo"
10
+
11
+ api "github.com/openmeterio/openmeter/api/v3"
12
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
13
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
14
+ "github.com/openmeterio/openmeter/pkg/models"
15
+ )
16
+
17
+ // providerDisplayNames maps canonical provider IDs to their formatted display names.
18
+ var providerDisplayNames = map[string]string{
19
+ "amazon": "Amazon",
20
+ "anthropic": "Anthropic",
21
+ "azure": "Azure",
22
+ "bedrock": "Amazon Bedrock",
23
+ "cohere": "Cohere",
24
+ "deepseek": "DeepSeek",
25
+ "google": "Google",
26
+ "kilo": "Kilo",
27
+ "meta": "Meta",
28
+ "minimax": "MiniMax",
29
+ "mistral": "Mistral",
30
+ "nanogpt": "NanoGPT",
31
+ "openai": "OpenAI",
32
+ "vertex_ai": "Google Vertex AI",
33
+ "xai": "xAI",
34
+ }
35
+
36
+ // formatProviderName returns the display name for a provider ID.
37
+ // Known providers get their canonical display name; unknown providers
38
+ // get their ID with the first letter capitalized.
39
+ func formatProviderName(id string) string {
40
+ if name, ok := providerDisplayNames[strings.ToLower(id)]; ok {
41
+ return name
42
+ }
43
+
44
+ if id == "" {
45
+ return ""
46
+ }
47
+
48
+ // Fallback: split on hyphens/underscores, capitalize each word
49
+ words := strings.FieldsFunc(id, func(r rune) bool {
50
+ return r == '-' || r == '_'
51
+ })
52
+ for i, w := range words {
53
+ if len(w) > 0 {
54
+ words[i] = strings.ToUpper(w[:1]) + w[1:]
55
+ }
56
+ }
57
+
58
+ return strings.Join(words, " ")
59
+ }
60
+
61
+ func domainPriceToAPI(p llmcost.Price) api.LLMCostPrice {
62
+ // Map internal source to API source: manual stays manual, everything else is system.
63
+ source := api.LLMCostPriceSourceSystem
64
+ if p.Source == llmcost.PriceSourceManual {
65
+ source = api.LLMCostPriceSourceManual
66
+ }
67
+
68
+ providerID := string(p.Provider)
69
+
70
+ out := api.LLMCostPrice{
71
+ Id: p.ID,
72
+ Provider: api.LLMCostProvider{
73
+ Id: providerID,
74
+ Name: formatProviderName(providerID),
75
+ },
76
+ Model: api.LLMCostModel{
77
+ Id: p.ModelID,
78
+ Name: p.ModelName,
79
+ },
80
+ Currency: p.Currency,
81
+ Source: source,
82
+ EffectiveFrom: p.EffectiveFrom,
83
+ EffectiveTo: p.EffectiveTo,
84
+ Pricing: domainPricingToAPI(p.Pricing),
85
+ }
86
+
87
+ out.CreatedAt = p.CreatedAt
88
+ out.UpdatedAt = p.UpdatedAt
89
+
90
+ return out
91
+ }
92
+
93
+ func domainPricingToAPI(p llmcost.ModelPricing) api.LLMCostModelPricing {
94
+ out := api.LLMCostModelPricing{
95
+ InputPerToken: p.InputPerToken.String(),
96
+ OutputPerToken: p.OutputPerToken.String(),
97
+ }
98
+
99
+ if p.CacheReadPerToken != nil {
100
+ out.CacheReadPerToken = lo.ToPtr(p.CacheReadPerToken.String())
101
+ }
102
+
103
+ if p.CacheWritePerToken != nil {
104
+ out.CacheWritePerToken = lo.ToPtr(p.CacheWritePerToken.String())
105
+ }
106
+
107
+ if p.ReasoningPerToken != nil {
108
+ out.ReasoningPerToken = lo.ToPtr(p.ReasoningPerToken.String())
109
+ }
110
+
111
+ return out
112
+ }
113
+
114
+ func apiPricingToDomain(p api.LLMCostModelPricing) (llmcost.ModelPricing, error) {
115
+ inputPerToken, err := decimalFromString(p.InputPerToken)
116
+ if err != nil {
117
+ return llmcost.ModelPricing{}, models.NewGenericValidationError(
118
+ fmt.Errorf("invalid input_per_token: %w", err),
119
+ )
120
+ }
121
+
122
+ outputPerToken, err := decimalFromString(p.OutputPerToken)
123
+ if err != nil {
124
+ return llmcost.ModelPricing{}, models.NewGenericValidationError(
125
+ fmt.Errorf("invalid output_per_token: %w", err),
126
+ )
127
+ }
128
+
129
+ out := llmcost.ModelPricing{
130
+ InputPerToken: inputPerToken,
131
+ OutputPerToken: outputPerToken,
132
+ }
133
+
134
+ if p.CacheReadPerToken != nil {
135
+ d, err := decimalFromString(*p.CacheReadPerToken)
136
+ if err != nil {
137
+ return llmcost.ModelPricing{}, models.NewGenericValidationError(
138
+ fmt.Errorf("invalid cache_read_per_token: %w", err),
139
+ )
140
+ }
141
+ out.CacheReadPerToken = &d
142
+ }
143
+
144
+ if p.ReasoningPerToken != nil {
145
+ d, err := decimalFromString(*p.ReasoningPerToken)
146
+ if err != nil {
147
+ return llmcost.ModelPricing{}, models.NewGenericValidationError(
148
+ fmt.Errorf("invalid reasoning_per_token: %w", err),
149
+ )
150
+ }
151
+ out.ReasoningPerToken = &d
152
+ }
153
+
154
+ if p.CacheWritePerToken != nil {
155
+ d, err := decimalFromString(*p.CacheWritePerToken)
156
+ if err != nil {
157
+ return llmcost.ModelPricing{}, models.NewGenericValidationError(
158
+ fmt.Errorf("invalid cache_write_per_token: %w", err),
159
+ )
160
+ }
161
+ out.CacheWritePerToken = &d
162
+ }
163
+
164
+ return out, nil
165
+ }
166
+
167
+ func apiCreateOverrideToDomain(ns string, body api.LLMCostOverrideCreate) (llmcost.CreateOverrideInput, error) {
168
+ pricing, err := apiPricingToDomain(body.Pricing)
169
+ if err != nil {
170
+ return llmcost.CreateOverrideInput{}, err
171
+ }
172
+
173
+ input := llmcost.CreateOverrideInput{
174
+ Namespace: ns,
175
+ Provider: llmcost.Provider(body.Provider),
176
+ ModelID: body.ModelId,
177
+ Pricing: pricing,
178
+ Currency: body.Currency,
179
+ EffectiveFrom: body.EffectiveFrom,
180
+ }
181
+
182
+ if body.ModelName != nil {
183
+ input.ModelName = *body.ModelName
184
+ }
185
+
186
+ if body.EffectiveTo != nil {
187
+ input.EffectiveTo = body.EffectiveTo
188
+ }
189
+
190
+ return input, nil
191
+ }
192
+
193
+ func decimalFromString(s string) (alpacadecimal.Decimal, error) {
194
+ v, err := alpacadecimal.NewFromString(s)
195
+ if err != nil {
196
+ return alpacadecimal.Decimal{}, models.NewGenericValidationError(
197
+ fmt.Errorf("invalid decimal: %w", err),
198
+ )
199
+ }
200
+
201
+ return v, nil
202
+ }
203
+
204
+ // FromAPILlmCostSortField validates a v3 (snake_case) LLM cost price sort field,
205
+ // returning it unchanged because the price adapter matches these wire strings
206
+ // directly. Returns a 400 for any unsupported field.
207
+ func FromAPILlmCostSortField(ctx context.Context, field string) (string, error) {
208
+ switch field {
209
+ case "id", "provider.id", "model.id", "effective_from", "effective_to":
210
+ return field, nil
211
+ default:
212
+ return "", apierrors.NewUnsupportedSortFieldError(
213
+ ctx, field, "id", "provider.id", "model.id", "effective_from", "effective_to",
214
+ )
215
+ }
216
+ }
api/v3/handlers/llmcost/convert_test.go ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package llmcost
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/samber/lo"
7
+ "github.com/stretchr/testify/assert"
8
+ "github.com/stretchr/testify/require"
9
+
10
+ api "github.com/openmeterio/openmeter/api/v3"
11
+ )
12
+
13
+ func TestFilterSourceInListPricesParams(t *testing.T) {
14
+ // Compile-time assertion that the generated API type includes the Source field.
15
+ filter := &api.ListLLMCostPricesParamsFilter{
16
+ Source: &api.StringFieldFilter{
17
+ Eq: lo.ToPtr("system"),
18
+ },
19
+ }
20
+ require.NotNil(t, filter.Source)
21
+ assert.Equal(t, lo.ToPtr("system"), filter.Source.Eq)
22
+ }
api/v3/handlers/llmcost/create_override.go ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package llmcost
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+
8
+ api "github.com/openmeterio/openmeter/api/v3"
9
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
10
+ "github.com/openmeterio/openmeter/api/v3/request"
11
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
12
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
13
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
14
+ )
15
+
16
+ type (
17
+ CreateOverrideRequest = llmcost.CreateOverrideInput
18
+ CreateOverrideResponse = api.LLMCostPrice
19
+ CreateOverrideHandler = httptransport.Handler[CreateOverrideRequest, CreateOverrideResponse]
20
+ )
21
+
22
+ func (h *handler) CreateOverride() CreateOverrideHandler {
23
+ return httptransport.NewHandler(
24
+ func(ctx context.Context, r *http.Request) (CreateOverrideRequest, error) {
25
+ var body api.LLMCostOverrideCreate
26
+ if err := request.ParseBody(r, &body); err != nil {
27
+ return CreateOverrideRequest{}, err
28
+ }
29
+
30
+ ns, err := h.resolveNamespace(ctx)
31
+ if err != nil {
32
+ return CreateOverrideRequest{}, err
33
+ }
34
+
35
+ return apiCreateOverrideToDomain(ns, body)
36
+ },
37
+ func(ctx context.Context, req CreateOverrideRequest) (CreateOverrideResponse, error) {
38
+ price, err := h.service.CreateOverride(ctx, req)
39
+ if err != nil {
40
+ return CreateOverrideResponse{}, fmt.Errorf("failed to create llm cost override: %w", err)
41
+ }
42
+
43
+ return domainPriceToAPI(price), nil
44
+ },
45
+ commonhttp.JSONResponseEncoderWithStatus[CreateOverrideResponse](http.StatusCreated),
46
+ httptransport.AppendOptions(
47
+ h.options,
48
+ httptransport.WithOperationName("create-llm-cost-override"),
49
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
50
+ )...,
51
+ )
52
+ }
api/v3/handlers/llmcost/delete_override.go ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package llmcost
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ api "github.com/openmeterio/openmeter/api/v3"
8
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
9
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
10
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
11
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
12
+ )
13
+
14
+ type (
15
+ DeleteOverrideRequest = llmcost.DeleteOverrideInput
16
+ DeleteOverrideResponse = interface{}
17
+ DeleteOverrideHandler = httptransport.HandlerWithArgs[DeleteOverrideRequest, DeleteOverrideResponse, api.ULID]
18
+ )
19
+
20
+ func (h *handler) DeleteOverride() DeleteOverrideHandler {
21
+ return httptransport.NewHandlerWithArgs(
22
+ func(ctx context.Context, r *http.Request, overrideID api.ULID) (DeleteOverrideRequest, error) {
23
+ ns, err := h.resolveNamespace(ctx)
24
+ if err != nil {
25
+ return DeleteOverrideRequest{}, err
26
+ }
27
+
28
+ return DeleteOverrideRequest{
29
+ ID: overrideID,
30
+ Namespace: ns,
31
+ }, nil
32
+ },
33
+ func(ctx context.Context, req DeleteOverrideRequest) (DeleteOverrideResponse, error) {
34
+ err := h.service.DeleteOverride(ctx, req)
35
+ if err != nil {
36
+ return nil, err
37
+ }
38
+
39
+ return nil, nil
40
+ },
41
+ commonhttp.EmptyResponseEncoder[DeleteOverrideResponse](http.StatusNoContent),
42
+ httptransport.AppendOptions(
43
+ h.options,
44
+ httptransport.WithOperationName("delete-llm-cost-override"),
45
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
46
+ )...,
47
+ )
48
+ }
api/v3/handlers/llmcost/get_price.go ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package llmcost
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ api "github.com/openmeterio/openmeter/api/v3"
8
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
9
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
10
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
11
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
12
+ )
13
+
14
+ type (
15
+ GetPriceRequest = llmcost.GetPriceInput
16
+ GetPriceResponse = api.LLMCostPrice
17
+ GetPriceHandler = httptransport.HandlerWithArgs[GetPriceRequest, GetPriceResponse, api.ULID]
18
+ )
19
+
20
+ func (h *handler) GetPrice() GetPriceHandler {
21
+ return httptransport.NewHandlerWithArgs(
22
+ func(ctx context.Context, r *http.Request, priceID api.ULID) (GetPriceRequest, error) {
23
+ ns, err := h.resolveNamespace(ctx)
24
+ if err != nil {
25
+ return GetPriceRequest{}, err
26
+ }
27
+
28
+ return GetPriceRequest{
29
+ ID: priceID,
30
+ Namespace: ns,
31
+ }, nil
32
+ },
33
+ func(ctx context.Context, request GetPriceRequest) (GetPriceResponse, error) {
34
+ price, err := h.service.GetPrice(ctx, request)
35
+ if err != nil {
36
+ return GetPriceResponse{}, err
37
+ }
38
+
39
+ return domainPriceToAPI(price), nil
40
+ },
41
+ commonhttp.JSONResponseEncoderWithStatus[GetPriceResponse](http.StatusOK),
42
+ httptransport.AppendOptions(
43
+ h.options,
44
+ httptransport.WithOperationName("get-llm-cost-price"),
45
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
46
+ )...,
47
+ )
48
+ }
api/v3/handlers/llmcost/handler.go ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package llmcost
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
7
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
8
+ )
9
+
10
+ type Handler interface {
11
+ ListPrices() ListPricesHandler
12
+ GetPrice() GetPriceHandler
13
+ ListOverrides() ListOverridesHandler
14
+ CreateOverride() CreateOverrideHandler
15
+ DeleteOverride() DeleteOverrideHandler
16
+ }
17
+
18
+ type handler struct {
19
+ resolveNamespace func(ctx context.Context) (string, error)
20
+ service llmcost.Service
21
+ options []httptransport.HandlerOption
22
+ }
23
+
24
+ func New(
25
+ resolveNamespace func(ctx context.Context) (string, error),
26
+ service llmcost.Service,
27
+ options ...httptransport.HandlerOption,
28
+ ) Handler {
29
+ return &handler{
30
+ resolveNamespace: resolveNamespace,
31
+ service: service,
32
+ options: options,
33
+ }
34
+ }
api/v3/handlers/llmcost/list_overrides.go ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package llmcost
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+
8
+ "github.com/samber/lo"
9
+
10
+ api "github.com/openmeterio/openmeter/api/v3"
11
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
12
+ "github.com/openmeterio/openmeter/api/v3/filters"
13
+ "github.com/openmeterio/openmeter/api/v3/response"
14
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
15
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
16
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
17
+ "github.com/openmeterio/openmeter/pkg/pagination"
18
+ )
19
+
20
+ type (
21
+ ListOverridesRequest = llmcost.ListOverridesInput
22
+ ListOverridesResponse = response.PagePaginationResponse[api.LLMCostPrice]
23
+ ListOverridesParams = api.ListLlmCostOverridesParams
24
+ ListOverridesHandler = httptransport.HandlerWithArgs[ListOverridesRequest, ListOverridesResponse, ListOverridesParams]
25
+ )
26
+
27
+ func (h *handler) ListOverrides() ListOverridesHandler {
28
+ return httptransport.NewHandlerWithArgs(
29
+ func(ctx context.Context, r *http.Request, params ListOverridesParams) (ListOverridesRequest, error) {
30
+ ns, err := h.resolveNamespace(ctx)
31
+ if err != nil {
32
+ return ListOverridesRequest{}, err
33
+ }
34
+
35
+ req := ListOverridesRequest{
36
+ Namespace: ns,
37
+ }
38
+
39
+ // Pagination
40
+ req.Page = pagination.NewPage(1, 20)
41
+
42
+ if params.Page != nil {
43
+ req.Page = pagination.NewPage(
44
+ lo.FromPtrOr(params.Page.Number, 1),
45
+ lo.FromPtrOr(params.Page.Size, 20),
46
+ )
47
+
48
+ if err := req.Page.Validate(); err != nil {
49
+ return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
50
+ {Field: "page", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
51
+ })
52
+ }
53
+ }
54
+
55
+ // Filters
56
+ if params.Filter != nil {
57
+ provider, err := filters.FromAPIFilterString(params.Filter.Provider)
58
+ if err != nil {
59
+ return ListOverridesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
60
+ {Field: "filter[provider]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
61
+ })
62
+ }
63
+ req.Provider = provider
64
+ modelID, err := filters.FromAPIFilterString(params.Filter.ModelId)
65
+ if err != nil {
66
+ return ListOverridesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
67
+ {Field: "filter[model_id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
68
+ })
69
+ }
70
+ req.ModelID = modelID
71
+ modelName, err := filters.FromAPIFilterString(params.Filter.ModelName)
72
+ if err != nil {
73
+ return ListOverridesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
74
+ {Field: "filter[model_name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
75
+ })
76
+ }
77
+ req.ModelName = modelName
78
+ currency, err := filters.FromAPIFilterString(params.Filter.Currency)
79
+ if err != nil {
80
+ return ListOverridesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
81
+ {Field: "filter[currency]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
82
+ })
83
+ }
84
+ req.Currency = currency
85
+ }
86
+
87
+ return req, nil
88
+ },
89
+ func(ctx context.Context, request ListOverridesRequest) (ListOverridesResponse, error) {
90
+ result, err := h.service.ListOverrides(ctx, request)
91
+ if err != nil {
92
+ return ListOverridesResponse{}, fmt.Errorf("failed to list llm cost overrides: %w", err)
93
+ }
94
+
95
+ items := lo.Map(result.Items, func(item llmcost.Price, _ int) api.LLMCostPrice {
96
+ return domainPriceToAPI(item)
97
+ })
98
+
99
+ return response.NewPagePaginationResponse(items, response.PageMetaPage{
100
+ Size: request.Page.PageSize,
101
+ Number: request.Page.PageNumber,
102
+ Total: lo.ToPtr(result.TotalCount),
103
+ }), nil
104
+ },
105
+ commonhttp.JSONResponseEncoderWithStatus[ListOverridesResponse](http.StatusOK),
106
+ httptransport.AppendOptions(
107
+ h.options,
108
+ httptransport.WithOperationName("list-llm-cost-overrides"),
109
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
110
+ )...,
111
+ )
112
+ }
api/v3/handlers/llmcost/list_prices.go ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package llmcost
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+
8
+ "github.com/samber/lo"
9
+
10
+ api "github.com/openmeterio/openmeter/api/v3"
11
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
12
+ "github.com/openmeterio/openmeter/api/v3/filters"
13
+ "github.com/openmeterio/openmeter/api/v3/request"
14
+ "github.com/openmeterio/openmeter/api/v3/response"
15
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
16
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
17
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
18
+ "github.com/openmeterio/openmeter/pkg/pagination"
19
+ )
20
+
21
+ type (
22
+ ListPricesRequest = llmcost.ListPricesInput
23
+ ListPricesResponse = response.PagePaginationResponse[api.LLMCostPrice]
24
+ ListPricesParams = api.ListLlmCostPricesParams
25
+ ListPricesHandler = httptransport.HandlerWithArgs[ListPricesRequest, ListPricesResponse, ListPricesParams]
26
+ )
27
+
28
+ func (h *handler) ListPrices() ListPricesHandler {
29
+ return httptransport.NewHandlerWithArgs(
30
+ func(ctx context.Context, r *http.Request, params ListPricesParams) (ListPricesRequest, error) {
31
+ ns, err := h.resolveNamespace(ctx)
32
+ if err != nil {
33
+ return ListPricesRequest{}, err
34
+ }
35
+
36
+ req := ListPricesRequest{
37
+ Namespace: ns,
38
+ }
39
+
40
+ // Pagination
41
+ req.Page = pagination.NewPage(1, 20)
42
+
43
+ if params.Page != nil {
44
+ req.Page = pagination.NewPage(
45
+ lo.FromPtrOr(params.Page.Number, 1),
46
+ lo.FromPtrOr(params.Page.Size, 20),
47
+ )
48
+
49
+ if err := req.Page.Validate(); err != nil {
50
+ return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
51
+ {Field: "page", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
52
+ })
53
+ }
54
+ }
55
+
56
+ // Sort
57
+ if params.Sort != nil {
58
+ sort, err := request.ParseSortBy(*params.Sort)
59
+ if err != nil {
60
+ return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
61
+ {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
62
+ })
63
+ }
64
+
65
+ orderBy, err := FromAPILlmCostSortField(ctx, sort.Field)
66
+ if err != nil {
67
+ return req, err
68
+ }
69
+
70
+ req.OrderBy = orderBy
71
+ req.Order = sort.Order.ToSortxOrder()
72
+ }
73
+
74
+ // Filters
75
+ if params.Filter != nil {
76
+ provider, err := filters.FromAPIFilterString(params.Filter.Provider)
77
+ if err != nil {
78
+ return ListPricesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
79
+ {Field: "filter[provider]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
80
+ })
81
+ }
82
+ req.Provider = provider
83
+ modelID, err := filters.FromAPIFilterString(params.Filter.ModelId)
84
+ if err != nil {
85
+ return ListPricesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
86
+ {Field: "filter[model_id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
87
+ })
88
+ }
89
+ req.ModelID = modelID
90
+ modelName, err := filters.FromAPIFilterString(params.Filter.ModelName)
91
+ if err != nil {
92
+ return ListPricesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
93
+ {Field: "filter[model_name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
94
+ })
95
+ }
96
+ req.ModelName = modelName
97
+ currency, err := filters.FromAPIFilterString(params.Filter.Currency)
98
+ if err != nil {
99
+ return ListPricesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
100
+ {Field: "filter[currency]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
101
+ })
102
+ }
103
+ req.Currency = currency
104
+ source, err := filters.FromAPIFilterString(params.Filter.Source)
105
+ if err != nil {
106
+ return ListPricesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
107
+ {Field: "filter[source]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
108
+ })
109
+ }
110
+ req.Source = source
111
+ }
112
+
113
+ return req, nil
114
+ },
115
+ func(ctx context.Context, request ListPricesRequest) (ListPricesResponse, error) {
116
+ result, err := h.service.ListPrices(ctx, request)
117
+ if err != nil {
118
+ return ListPricesResponse{}, fmt.Errorf("failed to list llm cost prices: %w", err)
119
+ }
120
+
121
+ items := lo.Map(result.Items, func(item llmcost.Price, _ int) api.LLMCostPrice {
122
+ return domainPriceToAPI(item)
123
+ })
124
+
125
+ return response.NewPagePaginationResponse(items, response.PageMetaPage{
126
+ Size: request.Page.PageSize,
127
+ Number: request.Page.PageNumber,
128
+ Total: lo.ToPtr(result.TotalCount),
129
+ }), nil
130
+ },
131
+ commonhttp.JSONResponseEncoderWithStatus[ListPricesResponse](http.StatusOK),
132
+ httptransport.AppendOptions(
133
+ h.options,
134
+ httptransport.WithOperationName("list-llm-cost-prices"),
135
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
136
+ )...,
137
+ )
138
+ }
api/v3/handlers/meters/convert.gen.go ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Code generated by github.com/jmattheis/goverter, DO NOT EDIT.
2
+ //go:build !goverter
3
+
4
+ package meters
5
+
6
+ import (
7
+ v3 "github.com/openmeterio/openmeter/api/v3"
8
+ response "github.com/openmeterio/openmeter/api/v3/response"
9
+ meter "github.com/openmeterio/openmeter/openmeter/meter"
10
+ "time"
11
+ )
12
+
13
+ func init() {
14
+ FromAPICreateMeterRequest = func(context string, source v3.CreateMeterRequest) (meter.CreateMeterInput, error) {
15
+ var meterCreateMeterInput meter.CreateMeterInput
16
+ meterCreateMeterInput.Namespace = NamespaceFromContext(context)
17
+ meterCreateMeterInput.Name = source.Name
18
+ meterCreateMeterInput.Key = source.Key
19
+ meterCreateMeterInput.Description = source.Description
20
+ meterCreateMeterInput.Aggregation = FromAPIMeterAggregation(source.Aggregation)
21
+ meterCreateMeterInput.EventType = source.EventType
22
+ meterCreateMeterInput.EventFrom = source.EventsFrom
23
+ meterCreateMeterInput.ValueProperty = source.ValueProperty
24
+ if source.Dimensions != nil {
25
+ meterCreateMeterInput.GroupBy = (*source.Dimensions)
26
+ }
27
+ modelsMetadata, err := ConvertLabelsToMetadata(source.Labels)
28
+ if err != nil {
29
+ return meterCreateMeterInput, err
30
+ }
31
+ meterCreateMeterInput.Metadata = modelsMetadata
32
+ return meterCreateMeterInput, nil
33
+ }
34
+ ToAPIMeter = func(source meter.Meter) v3.Meter {
35
+ var v3Meter v3.Meter
36
+ v3Meter.Aggregation = ToAPIMeterAggregation(source.Aggregation)
37
+ v3Meter.CreatedAt = timeTimeToTimeTime(source.ManagedResource.ManagedModel.CreatedAt)
38
+ v3Meter.DeletedAt = source.ManagedResource.ManagedModel.DeletedAt
39
+ v3Meter.Description = source.ManagedResource.Description
40
+ v3Meter.Dimensions = &source.GroupBy
41
+ v3Meter.EventType = source.EventType
42
+ v3Meter.EventsFrom = source.EventFrom
43
+ v3Meter.Id = source.ManagedResource.ID
44
+ v3Meter.Key = source.Key
45
+ v3Meter.Labels = ConvertMetadataAnnotationsToLabels(source)
46
+ v3Meter.Name = source.ManagedResource.Name
47
+ v3Meter.UpdatedAt = timeTimeToTimeTime(source.ManagedResource.ManagedModel.UpdatedAt)
48
+ v3Meter.ValueProperty = source.ValueProperty
49
+ return v3Meter
50
+ }
51
+ ToAPIMeterPagePaginatedResponse = func(source response.PagePaginationResponse[meter.Meter]) v3.MeterPagePaginatedResponse {
52
+ var v3MeterPagePaginatedResponse v3.MeterPagePaginatedResponse
53
+ if source.Data != nil {
54
+ v3MeterPagePaginatedResponse.Data = make([]v3.Meter, len(source.Data))
55
+ for i := 0; i < len(source.Data); i++ {
56
+ v3MeterPagePaginatedResponse.Data[i] = ToAPIMeter(source.Data[i])
57
+ }
58
+ }
59
+ v3MeterPagePaginatedResponse.Meta = responsePageMetaToV3PaginatedMeta(source.Meta)
60
+ return v3MeterPagePaginatedResponse
61
+ }
62
+ }
63
+ func responsePageMetaPageToV3PageMeta(source response.PageMetaPage) v3.PageMeta {
64
+ var v3PageMeta v3.PageMeta
65
+ v3PageMeta.Number = IntToFloat32(source.Number)
66
+ v3PageMeta.Size = IntToFloat32(source.Size)
67
+ if source.Total != nil {
68
+ v3PageMeta.Total = IntToFloat32(*source.Total)
69
+ }
70
+ return v3PageMeta
71
+ }
72
+ func responsePageMetaToV3PaginatedMeta(source response.PageMeta) v3.PaginatedMeta {
73
+ var v3PaginatedMeta v3.PaginatedMeta
74
+ v3PaginatedMeta.Page = responsePageMetaPageToV3PageMeta(source.Page)
75
+ return v3PaginatedMeta
76
+ }
77
+ func timeTimeToTimeTime(source time.Time) time.Time {
78
+ return source
79
+ }
api/v3/handlers/meters/convert.go ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //go:generate go run github.com/jmattheis/goverter/cmd/goverter gen ./
2
+ package meters
3
+
4
+ import (
5
+ "context"
6
+
7
+ "github.com/alpacahq/alpacadecimal"
8
+ "github.com/samber/lo"
9
+
10
+ api "github.com/openmeterio/openmeter/api/v3"
11
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
12
+ "github.com/openmeterio/openmeter/api/v3/handlers/meters/query"
13
+ "github.com/openmeterio/openmeter/api/v3/labels"
14
+ "github.com/openmeterio/openmeter/api/v3/response"
15
+ "github.com/openmeterio/openmeter/openmeter/meter"
16
+ "github.com/openmeterio/openmeter/pkg/models"
17
+ )
18
+
19
+ // goverter:variables
20
+ // goverter:skipCopySameType
21
+ // goverter:output:file ./convert.gen.go
22
+ // goverter:useZeroValueOnPointerInconsistency
23
+ // goverter:useUnderlyingTypeMethods
24
+ // goverter:matchIgnoreCase
25
+ // goverter:extend FromAPIMeterAggregation
26
+ // goverter:extend ToAPIMeterAggregation
27
+ // goverter:extend ConvertMetadataAnnotationsToLabels
28
+ // goverter:extend ConvertLabelsToMetadata
29
+ // goverter:extend IntToFloat32
30
+ var (
31
+ // goverter:context namespace
32
+ // goverter:map Namespace | NamespaceFromContext
33
+ // goverter:map Dimensions GroupBy
34
+ // goverter:map Labels Metadata
35
+ // goverter:map EventsFrom EventFrom
36
+ // goverter:ignore Annotations
37
+ // goverter:ignore inputOptions
38
+ FromAPICreateMeterRequest func(namespace string, createMeterRequest api.CreateMeterRequest) (meter.CreateMeterInput, error)
39
+ // goverter:map GroupBy Dimensions
40
+ // goverter:map EventFrom EventsFrom
41
+ // goverter:map ManagedResource.ID Id
42
+ // goverter:map ManagedResource.Description Description
43
+ // goverter:map ManagedResource.Name Name
44
+ // goverter:map ManagedResource.ManagedModel.CreatedAt CreatedAt
45
+ // goverter:map ManagedResource.ManagedModel.UpdatedAt UpdatedAt
46
+ // goverter:map ManagedResource.ManagedModel.DeletedAt DeletedAt
47
+ // goverter:map . Labels | ConvertMetadataAnnotationsToLabels
48
+ ToAPIMeter func(meter.Meter) api.Meter
49
+ ToAPIMeterPagePaginatedResponse func(meters response.PagePaginationResponse[meter.Meter]) api.MeterPagePaginatedResponse
50
+ )
51
+
52
+ var ConvertLabelsToMetadata = labels.ToMetadata
53
+
54
+ func ConvertMetadataAnnotationsToLabels(source meter.Meter) *api.Labels {
55
+ return labels.FromMetadataAnnotations(source.Metadata, source.Annotations)
56
+ }
57
+
58
+ //goverter:context namespace
59
+ func NamespaceFromContext(namespace string) string {
60
+ return namespace
61
+ }
62
+
63
+ func ToAPIMeterAggregation(aggregation meter.MeterAggregation) api.MeterAggregation {
64
+ switch aggregation {
65
+ case meter.MeterAggregationSum:
66
+ return api.MeterAggregationSum
67
+ case meter.MeterAggregationCount:
68
+ return api.MeterAggregationCount
69
+ case meter.MeterAggregationUniqueCount:
70
+ return api.MeterAggregationUniqueCount
71
+ case meter.MeterAggregationAvg:
72
+ return api.MeterAggregationAvg
73
+ case meter.MeterAggregationMin:
74
+ return api.MeterAggregationMin
75
+ case meter.MeterAggregationMax:
76
+ return api.MeterAggregationMax
77
+ case meter.MeterAggregationLatest:
78
+ return api.MeterAggregationLatest
79
+ }
80
+
81
+ return api.MeterAggregation("")
82
+ }
83
+
84
+ func FromAPIMeterAggregation(aggregation api.MeterAggregation) meter.MeterAggregation {
85
+ switch aggregation {
86
+ case api.MeterAggregationSum:
87
+ return meter.MeterAggregationSum
88
+ case api.MeterAggregationCount:
89
+ return meter.MeterAggregationCount
90
+ case api.MeterAggregationUniqueCount:
91
+ return meter.MeterAggregationUniqueCount
92
+ case api.MeterAggregationAvg:
93
+ return meter.MeterAggregationAvg
94
+ case api.MeterAggregationMin:
95
+ return meter.MeterAggregationMin
96
+ case api.MeterAggregationMax:
97
+ return meter.MeterAggregationMax
98
+ case api.MeterAggregationLatest:
99
+ return meter.MeterAggregationLatest
100
+ }
101
+
102
+ return ""
103
+ }
104
+
105
+ func IntToFloat32(i int) float32 {
106
+ return float32(i)
107
+ }
108
+
109
+ func FromAPIUpdateMeterRequest(namespace string, meterID string, body api.UpdateMeterRequest) (meter.UpdateMeterInput, error) {
110
+ input := meter.UpdateMeterInput{
111
+ ID: models.NamespacedID{
112
+ Namespace: namespace,
113
+ ID: meterID,
114
+ },
115
+ }
116
+
117
+ if body.Name != nil {
118
+ input.Name = *body.Name
119
+ }
120
+
121
+ input.Description = body.Description
122
+
123
+ if body.Dimensions != nil {
124
+ input.GroupBy = *body.Dimensions
125
+ }
126
+
127
+ if body.Labels != nil {
128
+ metadata, err := labels.ToMetadata(body.Labels)
129
+ if err != nil {
130
+ return meter.UpdateMeterInput{}, err
131
+ }
132
+
133
+ input.Metadata = metadata
134
+ }
135
+
136
+ return input, nil
137
+ }
138
+
139
+ // ConvertMetadataToLabels converts models.Metadata to api.Labels.
140
+ // Always returns an initialized map (never nil) so JSON serializes to {} instead of null.
141
+ func ConvertMetadataToLabels(source models.Metadata) *api.Labels {
142
+ labels := make(api.Labels)
143
+ for k, v := range source {
144
+ labels[k] = v
145
+ }
146
+ return &labels
147
+ }
148
+
149
+ func ToAPIMeterQueryRow(row meter.MeterQueryRow) api.MeterQueryRow {
150
+ dimensions := make(map[string]string)
151
+
152
+ if row.Subject != nil {
153
+ dimensions[query.DimensionSubject] = *row.Subject
154
+ }
155
+
156
+ if row.CustomerID != nil {
157
+ dimensions[query.DimensionCustomerID] = *row.CustomerID
158
+ }
159
+
160
+ for key, value := range row.GroupBy {
161
+ if key == query.DimensionSubject || key == query.DimensionCustomerID {
162
+ continue
163
+ }
164
+ if value != nil {
165
+ dimensions[key] = *value
166
+ }
167
+ }
168
+
169
+ return api.MeterQueryRow{
170
+ Value: alpacadecimal.NewFromFloat(row.Value).String(),
171
+ From: row.WindowStart,
172
+ To: row.WindowEnd,
173
+ Dimensions: dimensions,
174
+ }
175
+ }
176
+
177
+ func ToAPIMeterQueryResult(from *api.DateTime, to *api.DateTime, rows []meter.MeterQueryRow) api.MeterQueryResult {
178
+ return api.MeterQueryResult{
179
+ From: from,
180
+ To: to,
181
+ Data: lo.Map(rows, func(row meter.MeterQueryRow, _ int) api.MeterQueryRow {
182
+ return ToAPIMeterQueryRow(row)
183
+ }),
184
+ }
185
+ }
186
+
187
+ func FromAPIMeterSortField(ctx context.Context, field string) (meter.OrderBy, error) {
188
+ switch field {
189
+ case "key":
190
+ return meter.OrderByKey, nil
191
+ case "name":
192
+ return meter.OrderByName, nil
193
+ case "aggregation":
194
+ return meter.OrderByAggregation, nil
195
+ case "created_at":
196
+ return meter.OrderByCreatedAt, nil
197
+ case "updated_at":
198
+ return meter.OrderByUpdatedAt, nil
199
+ default:
200
+ return "", apierrors.NewUnsupportedSortFieldError(
201
+ ctx, field, "key", "name", "aggregation", "created_at", "updated_at",
202
+ )
203
+ }
204
+ }
api/v3/handlers/meters/create.go ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package meters
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ api "github.com/openmeterio/openmeter/api/v3"
8
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
9
+ "github.com/openmeterio/openmeter/api/v3/request"
10
+ "github.com/openmeterio/openmeter/openmeter/meter"
11
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
12
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
13
+ )
14
+
15
+ type (
16
+ CreateMeterRequest = meter.CreateMeterInput
17
+ CreateMeterResponse = api.Meter
18
+ CreateMeterHandler httptransport.Handler[CreateMeterRequest, CreateMeterResponse]
19
+ )
20
+
21
+ // CreateMeter returns a new httptransport.Handler for creating a meter.
22
+ func (h *handler) CreateMeter() CreateMeterHandler {
23
+ return httptransport.NewHandler(
24
+ func(ctx context.Context, r *http.Request) (CreateMeterRequest, error) {
25
+ body := api.CreateMeterRequest{}
26
+ if err := request.ParseBody(r, &body); err != nil {
27
+ return CreateMeterRequest{}, err
28
+ }
29
+
30
+ if body.Dimensions != nil {
31
+ if err := validateDimensionsWithoutReserved(*body.Dimensions); err != nil {
32
+ return CreateMeterRequest{}, err
33
+ }
34
+ }
35
+
36
+ ns, err := h.resolveNamespace(ctx)
37
+ if err != nil {
38
+ return CreateMeterRequest{}, err
39
+ }
40
+
41
+ return FromAPICreateMeterRequest(ns, body)
42
+ },
43
+ func(ctx context.Context, request CreateMeterRequest) (CreateMeterResponse, error) {
44
+ m, err := h.service.CreateMeter(ctx, request)
45
+ if err != nil {
46
+ return CreateMeterResponse{}, err
47
+ }
48
+
49
+ return ToAPIMeter(m), nil
50
+ },
51
+ commonhttp.JSONResponseEncoderWithStatus[CreateMeterResponse](http.StatusCreated),
52
+ httptransport.AppendOptions(
53
+ h.options,
54
+ httptransport.WithOperationName("create-meter"),
55
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
56
+ )...,
57
+ )
58
+ }
api/v3/handlers/meters/delete.go ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package meters
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
8
+ meter "github.com/openmeterio/openmeter/openmeter/meter"
9
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
10
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
11
+ )
12
+
13
+ type (
14
+ DeleteMeterRequest struct {
15
+ Namespace string
16
+ IDOrSlug string
17
+ }
18
+ DeleteMeterResponse = interface{}
19
+ DeleteMeterParams = string
20
+ DeleteMeterHandler httptransport.HandlerWithArgs[DeleteMeterRequest, DeleteMeterResponse, DeleteMeterParams]
21
+ )
22
+
23
+ // DeleteMeter returns a handler for deleting a meter.
24
+ func (h *handler) DeleteMeter() DeleteMeterHandler {
25
+ return httptransport.NewHandlerWithArgs(
26
+ func(ctx context.Context, r *http.Request, meterID DeleteMeterParams) (DeleteMeterRequest, error) {
27
+ ns, err := h.resolveNamespace(ctx)
28
+ if err != nil {
29
+ return DeleteMeterRequest{}, err
30
+ }
31
+
32
+ return DeleteMeterRequest{
33
+ Namespace: ns,
34
+ IDOrSlug: meterID,
35
+ }, nil
36
+ },
37
+ func(ctx context.Context, request DeleteMeterRequest) (DeleteMeterResponse, error) {
38
+ // FIXME: make delete idempotent, return 204 for repeated deletion
39
+ err := h.service.DeleteMeter(ctx, meter.DeleteMeterInput{
40
+ Namespace: request.Namespace,
41
+ IDOrSlug: request.IDOrSlug,
42
+ })
43
+ if err != nil {
44
+ return nil, err
45
+ }
46
+
47
+ return nil, nil
48
+ },
49
+ commonhttp.EmptyResponseEncoder[DeleteMeterResponse](http.StatusNoContent),
50
+ httptransport.AppendOptions(
51
+ h.options,
52
+ httptransport.WithOperationName("delete-meter"),
53
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
54
+ )...,
55
+ )
56
+ }
api/v3/handlers/meters/dimensions.go ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package meters
2
+
3
+ import "github.com/openmeterio/openmeter/api/v3/handlers/meters/query"
4
+
5
+ func validateDimensionsWithoutReserved[T any](dimensions map[string]T) error {
6
+ for dimension := range dimensions {
7
+ if query.IsReservedDimension(dimension) {
8
+ return NewReservedDimensionError(dimension)
9
+ }
10
+ }
11
+
12
+ return nil
13
+ }
api/v3/handlers/meters/errors.go ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package meters
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
7
+ "github.com/openmeterio/openmeter/pkg/models"
8
+ )
9
+
10
+ const ErrCodeReservedDimension models.ErrorCode = "reserved_dimension"
11
+
12
+ var ErrReservedDimension = models.NewValidationIssue(
13
+ ErrCodeReservedDimension,
14
+ "dimension name is reserved",
15
+ models.WithFieldString("dimensions"),
16
+ models.WithCriticalSeverity(),
17
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
18
+ )
19
+
20
+ func NewReservedDimensionError(dimension string) error {
21
+ return ErrReservedDimension.WithPathString("dimensions", dimension).WithAttr("value", dimension)
22
+ }
api/v3/handlers/meters/get.go ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package meters
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ api "github.com/openmeterio/openmeter/api/v3"
8
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
9
+ "github.com/openmeterio/openmeter/openmeter/meter"
10
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
11
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
12
+ )
13
+
14
+ type (
15
+ GetMeterRequest = meter.GetMeterInput
16
+ GetMeterResponse = api.Meter
17
+ GetMeterParams = string
18
+ GetMeterHandler httptransport.HandlerWithArgs[GetMeterRequest, GetMeterResponse, GetMeterParams]
19
+ )
20
+
21
+ // GetMeter returns a handler for getting a meter.
22
+ func (h *handler) GetMeter() GetMeterHandler {
23
+ return httptransport.NewHandlerWithArgs(
24
+ func(ctx context.Context, r *http.Request, meterID GetMeterParams) (GetMeterRequest, error) {
25
+ ns, err := h.resolveNamespace(ctx)
26
+ if err != nil {
27
+ return GetMeterRequest{}, err
28
+ }
29
+
30
+ return GetMeterRequest{
31
+ Namespace: ns,
32
+ IDOrSlug: meterID,
33
+ }, nil
34
+ },
35
+ func(ctx context.Context, request GetMeterRequest) (GetMeterResponse, error) {
36
+ // Get the meter
37
+ m, err := h.service.GetMeterByIDOrSlug(ctx, request)
38
+ if err != nil {
39
+ return GetMeterResponse{}, err
40
+ }
41
+
42
+ return ToAPIMeter(m), nil
43
+ },
44
+ commonhttp.JSONResponseEncoderWithStatus[GetMeterResponse](http.StatusOK),
45
+ httptransport.AppendOptions(
46
+ h.options,
47
+ httptransport.WithOperationName("get-meter"),
48
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
49
+ )...,
50
+ )
51
+ }
api/v3/handlers/meters/handler.go ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package meters
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/customer"
7
+ "github.com/openmeterio/openmeter/openmeter/meter"
8
+ "github.com/openmeterio/openmeter/openmeter/streaming"
9
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
10
+ )
11
+
12
+ type Handler interface {
13
+ ListMeters() ListMetersHandler
14
+ GetMeter() GetMeterHandler
15
+ CreateMeter() CreateMeterHandler
16
+ UpdateMeter() UpdateMeterHandler
17
+ DeleteMeter() DeleteMeterHandler
18
+ QueryMeter() QueryMeterHandler
19
+ QueryMeterCSV() QueryMeterCSVHandler
20
+ }
21
+
22
+ type handler struct {
23
+ resolveNamespace func(ctx context.Context) (string, error)
24
+ service meter.ManageService
25
+ streaming streaming.Connector
26
+ customerService customer.Service
27
+ options []httptransport.HandlerOption
28
+ }
29
+
30
+ func New(
31
+ resolveNamespace func(ctx context.Context) (string, error),
32
+ service meter.ManageService,
33
+ streaming streaming.Connector,
34
+ customerService customer.Service,
35
+ options ...httptransport.HandlerOption,
36
+ ) Handler {
37
+ return &handler{
38
+ resolveNamespace: resolveNamespace,
39
+ service: service,
40
+ streaming: streaming,
41
+ customerService: customerService,
42
+ options: options,
43
+ }
44
+ }
api/v3/handlers/meters/list.go ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package meters
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ "github.com/samber/lo"
8
+
9
+ api "github.com/openmeterio/openmeter/api/v3"
10
+ "github.com/openmeterio/openmeter/api/v3/apierrors"
11
+ "github.com/openmeterio/openmeter/api/v3/filters"
12
+ "github.com/openmeterio/openmeter/api/v3/request"
13
+ "github.com/openmeterio/openmeter/api/v3/response"
14
+ "github.com/openmeterio/openmeter/openmeter/meter"
15
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
16
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
17
+ "github.com/openmeterio/openmeter/pkg/pagination"
18
+ )
19
+
20
+ type (
21
+ ListMetersRequest = meter.ListMetersParams
22
+ ListMetersResponse = response.PagePaginationResponse[api.Meter]
23
+ ListMetersParams = api.ListMetersParams
24
+ ListMetersHandler httptransport.HandlerWithArgs[ListMetersRequest, ListMetersResponse, ListMetersParams]
25
+ )
26
+
27
+ func (h *handler) ListMeters() ListMetersHandler {
28
+ return httptransport.NewHandlerWithArgs(
29
+ func(ctx context.Context, r *http.Request, params ListMetersParams) (ListMetersRequest, error) {
30
+ ns, err := h.resolveNamespace(ctx)
31
+ if err != nil {
32
+ return ListMetersRequest{}, err
33
+ }
34
+
35
+ page := pagination.NewPage(1, 20)
36
+ if params.Page != nil {
37
+ page = pagination.NewPage(
38
+ lo.FromPtrOr(params.Page.Number, 1),
39
+ lo.FromPtrOr(params.Page.Size, 20),
40
+ )
41
+ }
42
+
43
+ if err := page.Validate(); err != nil {
44
+ return ListMetersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
45
+ apierrors.InvalidParameter{
46
+ Field: "page",
47
+ Reason: err.Error(),
48
+ Source: apierrors.InvalidParamSourceQuery,
49
+ },
50
+ })
51
+ }
52
+
53
+ req := ListMetersRequest{
54
+ Namespace: ns,
55
+ Page: page,
56
+ }
57
+
58
+ if params.Filter != nil {
59
+ key, err := filters.FromAPIFilterString(params.Filter.Key)
60
+ if err != nil {
61
+ return ListMetersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
62
+ {Field: "filter[key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
63
+ })
64
+ }
65
+ req.Key = key
66
+
67
+ name, err := filters.FromAPIFilterString(params.Filter.Name)
68
+ if err != nil {
69
+ return ListMetersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
70
+ {Field: "filter[name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
71
+ })
72
+ }
73
+ req.Name = name
74
+ }
75
+
76
+ if params.Sort != nil {
77
+ sort, err := request.ParseSortBy(*params.Sort)
78
+ if err != nil {
79
+ return ListMetersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
80
+ {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
81
+ })
82
+ }
83
+ orderBy, err := FromAPIMeterSortField(ctx, sort.Field)
84
+ if err != nil {
85
+ return ListMetersRequest{}, err
86
+ }
87
+ req.OrderBy = orderBy
88
+ req.Order = sort.Order.ToSortxOrder()
89
+ }
90
+
91
+ return req, nil
92
+ },
93
+ func(ctx context.Context, request ListMetersRequest) (ListMetersResponse, error) {
94
+ resp, err := h.service.ListMeters(ctx, request)
95
+ if err != nil {
96
+ return ListMetersResponse{}, err
97
+ }
98
+
99
+ items := lo.Map(resp.Items, func(item meter.Meter, _ int) api.Meter {
100
+ return ToAPIMeter(item)
101
+ })
102
+
103
+ r := response.NewPagePaginationResponse(items, response.PageMetaPage{
104
+ Size: request.Page.PageSize,
105
+ Number: request.Page.PageNumber,
106
+ Total: lo.ToPtr(resp.TotalCount),
107
+ })
108
+
109
+ return r, nil
110
+ },
111
+ commonhttp.JSONResponseEncoderWithStatus[ListMetersResponse](http.StatusOK),
112
+ httptransport.AppendOptions(
113
+ h.options,
114
+ httptransport.WithOperationName("list-meters"),
115
+ httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
116
+ )...,
117
+ )
118
+ }