Upload folder using huggingface_hub (part 4)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- api/v3/handlers/customers/credits/list_transactions.go +157 -0
- api/v3/handlers/customers/credits/list_transactions_test.go +117 -0
- api/v3/handlers/customers/credits/void_grant.go +55 -0
- api/v3/handlers/customers/credits/void_grant_test.go +71 -0
- api/v3/handlers/customers/delete.go +55 -0
- api/v3/handlers/customers/entitlementaccess/handler.go +34 -0
- api/v3/handlers/customers/entitlementaccess/list.go +97 -0
- api/v3/handlers/customers/entitlementaccess/mapping.go +46 -0
- api/v3/handlers/customers/get.go +53 -0
- api/v3/handlers/customers/handler.go +34 -0
- api/v3/handlers/customers/list.go +154 -0
- api/v3/handlers/customers/upsert.go +106 -0
- api/v3/handlers/events/convert.go +164 -0
- api/v3/handlers/events/handler.go +35 -0
- api/v3/handlers/events/ingest.go +106 -0
- api/v3/handlers/events/list.go +220 -0
- api/v3/handlers/events/list_test.go +110 -0
- api/v3/handlers/featurecost/convert.go +69 -0
- api/v3/handlers/featurecost/handler.go +42 -0
- api/v3/handlers/featurecost/query.go +97 -0
- api/v3/handlers/features/convert.go +352 -0
- api/v3/handlers/features/convert_test.go +574 -0
- api/v3/handlers/features/create.go +95 -0
- api/v3/handlers/features/create_test.go +60 -0
- api/v3/handlers/features/delete.go +49 -0
- api/v3/handlers/features/error_encoder.go +19 -0
- api/v3/handlers/features/get.go +66 -0
- api/v3/handlers/features/handler.go +42 -0
- api/v3/handlers/features/list.go +131 -0
- api/v3/handlers/features/update.go +66 -0
- api/v3/handlers/governance/handler.go +30 -0
- api/v3/handlers/governance/mapping.go +109 -0
- api/v3/handlers/governance/query.go +141 -0
- api/v3/handlers/llmcost/convert.go +216 -0
- api/v3/handlers/llmcost/convert_test.go +22 -0
- api/v3/handlers/llmcost/create_override.go +52 -0
- api/v3/handlers/llmcost/delete_override.go +48 -0
- api/v3/handlers/llmcost/get_price.go +48 -0
- api/v3/handlers/llmcost/handler.go +34 -0
- api/v3/handlers/llmcost/list_overrides.go +112 -0
- api/v3/handlers/llmcost/list_prices.go +138 -0
- api/v3/handlers/meters/convert.gen.go +79 -0
- api/v3/handlers/meters/convert.go +204 -0
- api/v3/handlers/meters/create.go +58 -0
- api/v3/handlers/meters/delete.go +56 -0
- api/v3/handlers/meters/dimensions.go +13 -0
- api/v3/handlers/meters/errors.go +22 -0
- api/v3/handlers/meters/get.go +51 -0
- api/v3/handlers/meters/handler.go +44 -0
- 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 = ¤cy
|
| 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 |
+
}
|