diff --git a/api/spec/packages/aip/src/llmcost/operations.tsp b/api/spec/packages/aip/src/llmcost/operations.tsp index b46afaf399..d21303fefc 100644 --- a/api/spec/packages/aip/src/llmcost/operations.tsp +++ b/api/spec/packages/aip/src/llmcost/operations.tsp @@ -17,24 +17,32 @@ namespace LLMCost; * TODO: This is a temporary solution to support the filter API. */ @friendlyName("FilterSingleString") +// @useRef("../../../../common/definitions/aip_filters.yaml#/components/schemas/StringFieldFilter") model FilterSingleString { /** * The field must match the provided value. */ - @extension("x-omitempty", true) eq?: string; + /** + * The field must match the provided value. This is an "override" filter that can be used when the default `eq` filter is not sufficient, e.g. when filtering by `model_name` where the name can have multiple variants for the same model ID. + */ + oeq?: string; + /** * The field must not match the provided value. */ - @extension("x-omitempty", true) neq?: string; /** * The field must contain the provided value. */ - @extension("x-omitempty", true) contains?: string; + + /** + * The field must contain the provided value. This is an "override" filter that can be used when the default `contains` filter is not sufficient. + */ + ocontains?: string; } /** @@ -44,16 +52,20 @@ model FilterSingleString { @friendlyName("ListLLMCostPricesParamsFilter") model ListPricesParamsFilter { /** Filter by provider. e.g. ?filter[provider][eq]=openai */ - provider?: FilterSingleString; + @extension("x-go-type", "FilterString") + provider?: string | FilterSingleString; /** Filter by model ID. e.g. ?filter[model_id][eq]=gpt-4 */ - model_id?: FilterSingleString; + @extension("x-go-type", "FilterString") + model_id?: string | FilterSingleString; /** Filter by model name. e.g. ?filter[model_name][contains]=gpt */ - model_name?: FilterSingleString; + @extension("x-go-type", "FilterString") + model_name?: string | FilterSingleString; /** Filter by currency code. e.g. ?filter[currency][eq]=USD */ - currency?: FilterSingleString; + @extension("x-go-type", "FilterString") + currency?: string | FilterSingleString; } /** diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index 703cabebcf..ae8bd34aa6 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -2271,6 +2271,12 @@ type FilterSingleString struct { // Neq The field must not match the provided value. Neq *string `json:"neq,omitempty"` + + // Ocontains The field must contain the provided value. This is an "override" filter that can be used when the default `contains` filter is not sufficient. + Ocontains *string `json:"ocontains,omitempty"` + + // Oeq The field must match the provided value. This is an "override" filter that can be used when the default `eq` filter is not sufficient, e.g. when filtering by `model_name` where the name can have multiple variants for the same model ID. + Oeq *string `json:"oeq,omitempty"` } // ForbiddenError defines model for ForbiddenError. @@ -2504,16 +2510,16 @@ type ListCustomersParamsFilter struct { // ListLLMCostPricesParamsFilter Filter options for listing LLM cost prices. type ListLLMCostPricesParamsFilter struct { // Currency Filter by currency code. e.g. ?filter[currency][eq]=USD - Currency *FilterSingleString `json:"currency,omitempty"` + Currency *FilterString `json:"currency,omitempty"` // ModelId Filter by model ID. e.g. ?filter[model_id][eq]=gpt-4 - ModelId *FilterSingleString `json:"model_id,omitempty"` + ModelId *FilterString `json:"model_id,omitempty"` // ModelName Filter by model name. e.g. ?filter[model_name][contains]=gpt - ModelName *FilterSingleString `json:"model_name,omitempty"` + ModelName *FilterString `json:"model_name,omitempty"` // Provider Filter by provider. e.g. ?filter[provider][eq]=openai - Provider *FilterSingleString `json:"provider,omitempty"` + Provider *FilterString `json:"provider,omitempty"` } // Meter A meter is a configuration that defines how to match and aggregate events. @@ -6168,344 +6174,346 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "JYRhQbmsY0d2bs5v/ncAAAD//8v90rF9EwIA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v3/api.go b/api/v3/api.go index a90249418c..673c602c36 100644 --- a/api/v3/api.go +++ b/api/v3/api.go @@ -1,2 +1,35 @@ //go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=codegen.yaml ./openapi.yaml package v3 + +import ( + "net/http" +) + +// FilterString A filter for a string field. +type FilterString struct { + // Contains The field must contain the provided value. + Contains *string `json:"contains,omitempty"` + + // Eq The field must match the provided value. + Eq *string `json:"eq,omitempty"` + + // Neq The field must not match the provided value. + Neq *string `json:"neq,omitempty"` + + // Ocontains asd + Ocontains *string `json:"ocontains,omitempty"` + + // Oeq aasd + Oeq *string `json:"oeq,omitempty"` +} + +func (f *FilterString) ParseEq(name string, r *http.Request) { + if f == nil { + f = &FilterString{} + } + query := r.URL.Query() + eq := query.Get(name) + if eq != "" { + f.Eq = &eq + } +} diff --git a/api/v3/filters/filter.go b/api/v3/filters/filter.go index fc2d0b2fb4..39691323b6 100644 --- a/api/v3/filters/filter.go +++ b/api/v3/filters/filter.go @@ -3,7 +3,6 @@ package filters import "errors" // StringFilter represents a filter operation on a string field. -// Exactly one of Eq, Neq, or Contains should be set. type StringFilter struct { // Eq requires the field to match the provided value exactly (case-insensitive). Eq *string `json:"eq,omitempty"` @@ -11,13 +10,43 @@ type StringFilter struct { // Neq requires the field to not match the provided value (case-insensitive). Neq *string `json:"neq,omitempty"` + // Gt requires the field to be greater than the provided value. + Gt *string `json:"gt,omitempty"` + + // Gte requires the field to be greater than or equal to the provided value. + Gte *string `json:"gte,omitempty"` + + // Lt requires the field to be less than the provided value. + Lt *string `json:"lt,omitempty"` + + // Lte requires the field to be less than or equal to the provided value. + Lte *string `json:"lte,omitempty"` + // Contains requires the field to contain the provided value (case-insensitive). Contains *string `json:"contains,omitempty"` + + // Oeq requires the field to match any of the provided comma-separated values (case-insensitive). + Oeq *string `json:"oeq,omitempty"` + + // Ocontains requires the field to contain any of the provided comma-separated values (case-insensitive). + Ocontains *string `json:"ocontains,omitempty"` + + // Exists requires the field to be present (true) or absent (false). + Exists *bool `json:"exists,omitempty"` } // IsEmpty returns true if no filter operator is set. func (f StringFilter) IsEmpty() bool { - return f.Eq == nil && f.Neq == nil && f.Contains == nil + return f.Eq == nil && + f.Neq == nil && + f.Gt == nil && + f.Gte == nil && + f.Lt == nil && + f.Lte == nil && + f.Contains == nil && + f.Oeq == nil && + f.Ocontains == nil && + f.Exists == nil } // Validate validates the filter. @@ -26,7 +55,6 @@ func (f StringFilter) Validate() error { return nil } - // Check for mutually exclusive filters if f.Eq != nil && f.Neq != nil { return errors.New("eq and neq cannot be set at the same time") } diff --git a/api/v3/handlers/llmcost/convert.go b/api/v3/handlers/llmcost/convert.go index b4d902b7ca..68d1454d64 100644 --- a/api/v3/handlers/llmcost/convert.go +++ b/api/v3/handlers/llmcost/convert.go @@ -203,7 +203,7 @@ func validPriceSortField(field string) bool { // filterSingleStringToDomain converts an API FilterSingleString to the domain StringFilter. // Returns nil if the input is nil or empty. -func filterSingleStringToDomain(f *api.FilterSingleString) (*filters.StringFilter, error) { +func filterSingleStringToDomain(f *api.FilterString) (*filters.StringFilter, error) { if f == nil { return nil, nil } diff --git a/api/v3/handlers/llmcost/list_overrides.go b/api/v3/handlers/llmcost/list_overrides.go index 18e3aabcff..1074b44445 100644 --- a/api/v3/handlers/llmcost/list_overrides.go +++ b/api/v3/handlers/llmcost/list_overrides.go @@ -2,7 +2,9 @@ package llmcost import ( "context" + "encoding/json" "fmt" + "log/slog" "net/http" "github.com/samber/lo" @@ -53,24 +55,28 @@ func (h *handler) ListOverrides() ListOverridesHandler { // Filters if params.Filter != nil { + params.Filter.Provider.ParseEq("filter[provider]", r) provider, err := filterSingleStringToDomain(params.Filter.Provider) if err != nil { return req, err } req.Provider = provider + params.Filter.ModelId.ParseEq("filter[model_id]", r) modelID, err := filterSingleStringToDomain(params.Filter.ModelId) if err != nil { return req, err } req.ModelID = modelID + params.Filter.ModelName.ParseEq("filter[model_name]", r) modelName, err := filterSingleStringToDomain(params.Filter.ModelName) if err != nil { return req, err } req.ModelName = modelName + params.Filter.Currency.ParseEq("filter[currency]", r) currency, err := filterSingleStringToDomain(params.Filter.Currency) if err != nil { return req, err @@ -78,6 +84,12 @@ func (h *handler) ListOverrides() ListOverridesHandler { req.Currency = currency } + j, err := json.Marshal(req) + if err != nil { + return req, err + } + slog.Info("req", "req", string(j)) + return req, nil }, func(ctx context.Context, request ListOverridesRequest) (ListOverridesResponse, error) { diff --git a/api/v3/handlers/llmcost/list_prices.go b/api/v3/handlers/llmcost/list_prices.go index 4c2c913726..6d3b641bac 100644 --- a/api/v3/handlers/llmcost/list_prices.go +++ b/api/v3/handlers/llmcost/list_prices.go @@ -24,78 +24,77 @@ type ( ListPricesHandler = httptransport.HandlerWithArgs[ListPricesRequest, ListPricesResponse, ListPricesParams] ) +var listPricesAuthorizedFilters = map[string]request.AIPFilterOption{ + "provider": { + Filters: []request.QueryFilterOp{ + request.QueryFilterEQ, + request.QueryFilterNEQ, + request.QueryFilterContains, + request.QueryFilterOrContains, + }, + }, + "model_id": { + Filters: []request.QueryFilterOp{ + request.QueryFilterEQ, + request.QueryFilterNEQ, + request.QueryFilterContains, + }, + }, + "model_name": { + Filters: []request.QueryFilterOp{ + request.QueryFilterEQ, + request.QueryFilterNEQ, + request.QueryFilterContains, + }, + }, + "currency": { + Filters: []request.QueryFilterOp{ + request.QueryFilterEQ, + request.QueryFilterNEQ, + request.QueryFilterContains, + }, + }, +} + +var listPricesAuthorizedSorts = []string{ + "id", "provider.id", "model.id", "effective_from", "effective_to", +} + func (h *handler) ListPrices() ListPricesHandler { return httptransport.NewHandlerWithArgs( - func(ctx context.Context, r *http.Request, params ListPricesParams) (ListPricesRequest, error) { + func(ctx context.Context, r *http.Request, _ ListPricesParams) (ListPricesRequest, error) { ns, err := h.resolveNamespace(ctx) if err != nil { return ListPricesRequest{}, err } - req := ListPricesRequest{ - Namespace: ns, + attrs, err := request.GetAipAttributes(r, + request.WithDefaultPageSize(20), + request.WithMaxPageSize(100), + request.WithAuthorizedSorts(listPricesAuthorizedSorts), + request.WithAuthorizedFilters(listPricesAuthorizedFilters), + ) + if err != nil { + return ListPricesRequest{}, err } - // Pagination - req.Page = pagination.NewPage(1, 20) - - if params.Page != nil { - req.Page = pagination.NewPage( - lo.FromPtrOr(params.Page.Number, 1), - lo.FromPtrOr(params.Page.Size, 20), - ) - - if err := req.Page.Validate(); err != nil { - return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ - {Field: "page", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, - }) - } + pageNumber := attrs.Pagination.Number + if pageNumber < 1 { + pageNumber = 1 } - // Sort - if params.Sort != nil { - sort, err := request.ParseSortBy(*params.Sort) - if err != nil { - return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ - {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, - }) - } - - if !validPriceSortField(sort.Field) { - return req, apierrors.NewBadRequestError(ctx, fmt.Errorf("unsupported sort field: %s", sort.Field), apierrors.InvalidParameters{ - {Field: "sort", Reason: fmt.Sprintf("unsupported sort field %q, must be one of: id, provider.id, model.id, effective_from, effective_to", sort.Field), Source: apierrors.InvalidParamSourceQuery}, - }) - } - - req.OrderBy = sort.Field - req.Order = sort.Order.ToSortxOrder() + req := ListPricesRequest{ + Namespace: ns, + Page: pagination.NewPage(pageNumber, attrs.Pagination.Size), + Provider: request.FilterStringFromAip(attrs.Filters, "provider"), + ModelID: request.FilterStringFromAip(attrs.Filters, "model_id"), + ModelName: request.FilterStringFromAip(attrs.Filters, "model_name"), + Currency: request.FilterStringFromAip(attrs.Filters, "currency"), } - // Filters - if params.Filter != nil { - provider, err := filterSingleStringToDomain(params.Filter.Provider) - if err != nil { - return req, err - } - req.Provider = provider - - modelID, err := filterSingleStringToDomain(params.Filter.ModelId) - if err != nil { - return req, err - } - req.ModelID = modelID - - modelName, err := filterSingleStringToDomain(params.Filter.ModelName) - if err != nil { - return req, err - } - req.ModelName = modelName - - currency, err := filterSingleStringToDomain(params.Filter.Currency) - if err != nil { - return req, err - } - req.Currency = currency + if len(attrs.Sorts) > 0 { + req.OrderBy = attrs.Sorts[0].Field + req.Order = attrs.Sorts[0].Order.ToSortxOrder() } return req, nil diff --git a/api/v3/oasmiddleware/decoder.go b/api/v3/oasmiddleware/decoder.go deleted file mode 100644 index 60e11b895d..0000000000 --- a/api/v3/oasmiddleware/decoder.go +++ /dev/null @@ -1,25 +0,0 @@ -package oasmiddleware - -import ( - "encoding/json" - "io" - "net/http" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/openapi3filter" -) - -// ref: https://github.com/getkin/kin-openapi/blob/994d4f01c1e8dd613805668a7c10b568547f7789/openapi3filter/req_resp_decoder.go#L1031-L1047 - -// JsonBodyDecoder is meant to be used with openapi3filter.RegisterBodyDecoder -// to register a decoder for a custom vendor type like "application/konnect.foo+json" -func JsonBodyDecoder(body io.Reader, _ http.Header, _ *openapi3.SchemaRef, _ openapi3filter.EncodingFn) (any, error) { - var value any - dec := json.NewDecoder(body) - dec.UseNumber() - if err := dec.Decode(&value); err != nil { - return nil, &openapi3filter.ParseError{Kind: openapi3filter.KindInvalidFormat, Cause: err} - } - - return value, nil -} diff --git a/api/v3/oasmiddleware/error.go b/api/v3/oasmiddleware/error.go index be0dc0d1db..20d6a32c1a 100644 --- a/api/v3/oasmiddleware/error.go +++ b/api/v3/oasmiddleware/error.go @@ -1,12 +1,9 @@ package oasmiddleware import ( - "errors" - "fmt" "strings" - "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/openapi3filter" + validatorerrors "github.com/pb33f/libopenapi-validator/errors" "github.com/openmeterio/openmeter/api/v3/apierrors" ) @@ -18,126 +15,75 @@ var oasRuleToAip = map[string]string{ "maxItems": "max_items", } -func ToAipError(me openapi3.MultiError) []apierrors.InvalidParameter { - return aipMapper(me, nil) -} - -func aipMapper(me openapi3.MultiError, parent *apierrors.InvalidParameter) []apierrors.InvalidParameter { +// ToAipErrorFromLibopenapi converts libopenapi ValidationErrors to AIP InvalidParameters. +func ToAipErrorFromLibopenapi(errs []*validatorerrors.ValidationError) []apierrors.InvalidParameter { var ipErrs []apierrors.InvalidParameter - for _, err := range me { - var i *apierrors.InvalidParameter - if parent != nil { - i = parent - } else { - i = &apierrors.InvalidParameter{} + for _, ve := range errs { + if ve == nil { + continue } - switch err := err.(type) { - case *openapi3.SchemaError: - i.Reason = err.Reason - ipErrs = append(ipErrs, invalidParamFromSchemaError(err, i)) - case *openapi3filter.RequestError: - if err.Parameter != nil { - if err.Parameter.Name != "" { - i.Field = err.Parameter.Name - } - if err.Parameter.In != "" { - i.Source = apierrors.ToInvalid(err.Parameter.In) - } - if err.Parameter.Required { - i.Rule = "required" - } - } - i.Reason = err.Reason - if err.Reason == "" || err.RequestBody != nil { - i.Reason = err.Error() - } - - if err, ok := err.Err.(openapi3.MultiError); ok { - ipErrs = append(ipErrs, aipMapper(err, i)...) - continue + ip := apierrors.InvalidParameter{ + Field: ve.ParameterName, + Reason: ve.Reason, + Rule: ruleFromValidationError(ve), + Source: sourceFromValidationError(ve), + } + if ip.Field == "" && len(ve.SchemaValidationErrors) > 0 { + // Use field path from schema errors if no parameter name + sve := ve.SchemaValidationErrors[0] + if sve.FieldName != "" { + ip.Field = sve.FieldName + } else if sve.FieldPath != "" { + ip.Field = strings.TrimPrefix(strings.TrimPrefix(sve.FieldPath, "$."), "body.") } - - if err, ok := err.Err.(*openapi3.SchemaError); ok { - i.Choices = make([]string, 0) - if err.SchemaField == "enum" { - i.Rule = "enum" - for _, v := range err.Schema.Enum { - i.Choices = append(i.Choices, fmt.Sprintf("%v", v)) - } - i.Reason = fmt.Sprintf("must be one of: [%s]", strings.Join(i.Choices, ",")) - } else if err.SchemaField == "oneOf" { - ipErrs = append(ipErrs, collectFromSchemaError(err)...) - continue + } + if len(ve.SchemaValidationErrors) > 0 { + // Extract enum choices from schema validation errors if applicable + for _, sve := range ve.SchemaValidationErrors { + if sve.Reason != "" && ip.Reason == "" { + ip.Reason = sve.Reason } } - ipErrs = append(ipErrs, *i) } + ipErrs = append(ipErrs, ip) } return ipErrs } -// collectFromSchemaError looks at schemaErr.Origin. If there are deeper -// child errors (via unwrapOriginError), it returns those. Otherwise, it -// returns a single InvalidParameter built from schemaErr itself. -func collectFromSchemaError(se *openapi3.SchemaError) []apierrors.InvalidParameter { - childParams := unwrapOriginError(se) - if len(childParams) == 0 { - return []apierrors.InvalidParameter{ - invalidParamFromSchemaError(se, nil), - } +func ruleFromValidationError(ve *validatorerrors.ValidationError) string { + if r, ok := oasRuleToAip[ve.ValidationSubType]; ok { + return r } - return childParams -} - -// unwrapOriginError traverses schemaErr.Origin (which may be a wrapped multiErrorForOneOf) -// and returns a flat slice of InvalidParameter entries for each underlying *SchemaError. -func unwrapOriginError(schemaErr *openapi3.SchemaError) []apierrors.InvalidParameter { - if schemaErr == nil || schemaErr.Origin == nil { - return nil + if ve.ValidationSubType != "" { + return ve.ValidationSubType } - - // 1) First, try to pull out a MultiError (or multiErrorForOneOf) from the wrapper chain. - var me openapi3.MultiError - if errors.As(schemaErr.Origin, &me) { - var result []apierrors.InvalidParameter - for _, subErr := range me { - var subSE *openapi3.SchemaError - if errors.As(subErr, &subSE) { - result = append(result, collectFromSchemaError(subSE)...) - } - } - return result + if ve.ValidationType != "" { + return ve.ValidationType } - - // 2) If there are no multi-errors and Origin wraps another *SchemaError somewhere in its chain, dive into that. - var innerSE *openapi3.SchemaError - if errors.As(schemaErr.Origin, &innerSE) { - return collectFromSchemaError(innerSE) - } - - // 3) If we reach here, Origin was neither a nested *SchemaError nor a MultiError. - return nil + return "" } -func invalidParamFromSchemaError( - schemaErr *openapi3.SchemaError, - parent *apierrors.InvalidParameter, -) apierrors.InvalidParameter { - var ip *apierrors.InvalidParameter - if parent != nil { - ip = parent - } else { - ip = &apierrors.InvalidParameter{ - Reason: schemaErr.Reason, - } - } - if rule, ok := oasRuleToAip[schemaErr.SchemaField]; ok { - ip.Rule = rule - } else { - ip.Rule = schemaErr.SchemaField +func sourceFromValidationError(ve *validatorerrors.ValidationError) apierrors.InvalidParameterSource { + // libopenapi uses: path, query, header, cookie for parameter validation + switch strings.ToLower(ve.ValidationType) { + case "path": + return apierrors.InvalidParamSourcePath + case "query": + return apierrors.InvalidParamSourceQuery + case "header": + return apierrors.InvalidParamSourceHeader + case "requestbody", "schema": + return apierrors.InvalidParamSourceBody } - if path := schemaErr.JSONPointer(); len(path) > 0 { - ip.Field = strings.Join(path, ".") + switch strings.ToLower(ve.ValidationSubType) { + case "path": + return apierrors.InvalidParamSourcePath + case "query": + return apierrors.InvalidParamSourceQuery + case "header": + return apierrors.InvalidParamSourceHeader + case "requestbody", "schema": + return apierrors.InvalidParamSourceBody } - return *ip + return apierrors.InvalidParamSourceBody } diff --git a/api/v3/oasmiddleware/hook.go b/api/v3/oasmiddleware/hook.go index 480356a9f0..b2cda8d5ad 100644 --- a/api/v3/oasmiddleware/hook.go +++ b/api/v3/oasmiddleware/hook.go @@ -5,16 +5,13 @@ import ( "errors" "net/http" - "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/openapi3filter" - "github.com/openmeterio/openmeter/api/v3/apierrors" ) var ErrRouteNotFound = errors.New("route not found") -// OasRouteNotFoundErrorHook handles the error when a route is not found in a validation -// router. This will stop the request lifecycle and return an AIP compliant 404 response +// OasRouteNotFoundErrorHook handles the error when a route is not found in validation. +// It stops the request lifecycle and returns an AIP-compliant 404 response. func OasRouteNotFoundErrorHook(err error, w http.ResponseWriter, r *http.Request) bool { if err != nil { apierrors. @@ -25,82 +22,38 @@ func OasRouteNotFoundErrorHook(err error, w http.ResponseWriter, r *http.Request return false } -// OasValidationErrorHook handles the error when a request is not matching the -// OAS spec definition for a given route in the validation router. -// This will stop the request lifecycle and return an AIP compliant 400 response +// OasValidationErrorHook handles the error when a request does not match the OAS spec. +// It stops the request lifecycle and returns an AIP-compliant 400 or 404 response. func OasValidationErrorHook(ctx context.Context, err error, w http.ResponseWriter, r *http.Request) bool { - switch err := err.(type) { - case nil: + if err == nil { return false - case openapi3.MultiError: - invalidParams := ToAipError(err) - sourcePath := false - for _, v := range invalidParams { - if v.Source == apierrors.InvalidParamSourcePath { - sourcePath = true - break - } - } - if sourcePath { - apierrors. - NewNotFoundError(ctx, err, "entity"). - HandleAPIError(w, r) - } else { - apierrors. - NewBadRequestError(ctx, SanitizeSensitiveFieldValues(err), invalidParams). - HandleAPIError(w, r) - } + } + + var ve *LibopenapiValidationErrors + if !errors.As(err, &ve) { + apierrors. + NewBadRequestError(ctx, err, nil). + HandleAPIError(w, r) return true - case *openapi3filter.RequestError: - if err.Parameter != nil && err.Parameter.In == "path" { - apierrors. - NewNotFoundError(ctx, err, "entity"). - HandleAPIError(w, r) - return true - } } - apierrors. - NewBadRequestError(ctx, err, nil). - HandleAPIError(w, r) - return true -} -func SanitizeSensitiveFieldValues(err error) error { - switch err := err.(type) { - case nil: - return nil - case openapi3.MultiError: - sanitizedMultiErr := make(openapi3.MultiError, 0) - for _, vErr := range err { - sanitizedMultiErr = append(sanitizedMultiErr, SanitizeSensitiveFieldValues(vErr)) + invalidParams := ToAipErrorFromLibopenapi(ve.Errors) + sourcePath := false + for _, v := range invalidParams { + if v.Source == apierrors.InvalidParamSourcePath { + sourcePath = true + break } - return sanitizedMultiErr - case *openapi3filter.RequestError: - err.Err = SanitizeSensitiveFieldValues(err.Err) - return err - case *openapi3.SchemaError: - if err.Schema != nil && err.Schema.Extensions != nil { - xSensitive, ok := err.Schema.Extensions["x-sensitive"] - if ok && isSensitive(xSensitive) { - err.Value = "********" - } - } - return err - default: - return err } -} -func isSensitive(sensitive any) bool { - switch v := sensitive.(type) { - case string: - if v == "true" { - return true - } - return false - case bool: - return v - default: - return false + if sourcePath { + apierrors. + NewNotFoundError(ctx, err, "entity"). + HandleAPIError(w, r) + } else { + apierrors. + NewBadRequestError(ctx, err, invalidParams). + HandleAPIError(w, r) } + return true } diff --git a/api/v3/oasmiddleware/router.go b/api/v3/oasmiddleware/router.go deleted file mode 100644 index 98de3d5320..0000000000 --- a/api/v3/oasmiddleware/router.go +++ /dev/null @@ -1,60 +0,0 @@ -package oasmiddleware - -import ( - "context" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/routers" - "github.com/getkin/kin-openapi/routers/gorillamux" -) - -// ValidationRouterOpts represents the options to be passed to the validation router for processing the requests. -type ValidationRouterOpts struct { - // DeleteServers removes the `Servers` property from the parsed OAS spec to be used within test or even at runtime. - // If you want to use it at runtime please read the doc for `ServerPrefix` first. - DeleteServers bool - // ServerPrefix adds a server entry with the desired prefix - // eg: the service expose domain.com/foo/v0/entity . Your spec path are defined at the /entity level and then - // /foo/v0 is part of the server entry in the OAS spec. If no prefix is provided, the validation router will either - // take the whole server entry like domain.com/foo/v0/entity to validate or if `DeleteServers` is to true it will - // only validate `/entity` - ServerPrefix string -} - -// NewValidationRouter creates a validation router to be injected in the middlewares -// to validate requests or responses. In a case of a bad spec it returns an error -func NewValidationRouter(ctx context.Context, doc *openapi3.T, opts *ValidationRouterOpts) (routers.Router, error) { - if opts == nil { - opts = &ValidationRouterOpts{ - DeleteServers: true, - } - } - - if opts.DeleteServers { - doc.Servers = nil - - for key, pathItem := range doc.Paths.Map() { - pathItem.Servers = nil - doc.Paths.Set(key, pathItem) - } - } - - if opts.ServerPrefix != "" { - doc.Servers = openapi3.Servers{ - &openapi3.Server{ - URL: opts.ServerPrefix, - }, - } - } - - if err := doc.Validate(ctx); err != nil { - return nil, err - } - - validationRouter, err := gorillamux.NewRouter(doc) - if err != nil { - return nil, err - } - - return validationRouter, err -} diff --git a/api/v3/oasmiddleware/validator.go b/api/v3/oasmiddleware/validator.go index 71d811c1f0..453aec2adf 100644 --- a/api/v3/oasmiddleware/validator.go +++ b/api/v3/oasmiddleware/validator.go @@ -2,11 +2,17 @@ package oasmiddleware import ( "bytes" + "encoding/json" + "errors" + "fmt" "io" "net/http" + "strings" - "github.com/getkin/kin-openapi/openapi3filter" - "github.com/getkin/kin-openapi/routers" + "github.com/pb33f/libopenapi" + validator "github.com/pb33f/libopenapi-validator" + validatorerrors "github.com/pb33f/libopenapi-validator/errors" + "gopkg.in/yaml.v3" ) type ( @@ -15,42 +21,130 @@ type ( ResponseValidationFunc = func(error, *http.Request) ) -// ValidateRequestOption provides the hook functions and the openapi3filter -// option to be passed in to the underlying library +// ValidateRequestOption provides the hook functions for the validation middleware. type ValidateRequestOption struct { - // RouteNotFoundHook is called when the route is not found at the spec level - // if the hook returns `true` the request flow is stopped + // RouteNotFoundHook is called when the route is not found at the spec level. + // If the hook returns `true` the request flow is stopped. RouteNotFoundHook RequestNotFoundHookFunc // RouteValidationErrorHook is called when the route parameters or body are - // not validated. if the hook returns `true` the request flow is stopped + // not validated. If the hook returns `true` the request flow is stopped. RouteValidationErrorHook RequestValidationErrorFunc - // FilterOptions are the openapi3filter option to pass to the underlying lib - FilterOptions *openapi3filter.Options } -// ValidateRequest is the middleware to be used to validate the request to the spec -// passed in for the validation router -func ValidateRequest(validationRouter routers.Router, opts ValidateRequestOption) func(h http.Handler) http.Handler { +// ValidateResponseOption provides the hook function for response validation. +type ValidateResponseOption struct { + // ResponseValidationErrorHook is called when the route response body is not validated. + ResponseValidationErrorHook ResponseValidationFunc +} + +// NewValidator creates a libopenapi-validator from the given spec bytes and base URL. +// The baseURL is set as the server URL for path matching (e.g. /api/v3). +func NewValidator(specBytes []byte, baseURL string) (validator.Validator, error) { + patched, err := patchSpecServers(specBytes, baseURL) + if err != nil { + return nil, err + } + + document, err := libopenapi.NewDocument(patched) + if err != nil { + return nil, err + } + + v, errs := validator.NewValidator(document) + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + return v, nil +} + +// patchSpecServers modifies the OpenAPI spec to set the servers URL for path matching. +func patchSpecServers(specBytes []byte, baseURL string) ([]byte, error) { + var spec map[string]any + if err := json.Unmarshal(specBytes, &spec); err != nil { + if err := yaml.Unmarshal(specBytes, &spec); err != nil { + return nil, fmt.Errorf("parse spec: %w", err) + } + } + + spec["servers"] = []map[string]any{ + {"url": baseURL}, + } + + return json.Marshal(spec) +} + +// filterQueryParamErrors removes validation errors for multi-level nested deepObject +// query params. libopenapi-validator has a known issue: it works for objects with +// depth of one but fails for nested objects (e.g. filter[provider][eq]=x). +// See https://github.com/pb33f/libopenapi-validator/issues/83 +func filterQueryParamErrors(errs []*validatorerrors.ValidationError) []*validatorerrors.ValidationError { + if len(errs) == 0 { + return errs + } + var filtered []*validatorerrors.ValidationError + for _, e := range errs { + if e == nil { + continue + } + // Only skip errors for object-type query params that failed schema validation + // (e.g. "The query parameter 'filter' is defined as an object, however it + // failed to pass a schema validation"). Simple query params are still validated. + msg := strings.ToLower(e.Message) + reason := strings.ToLower(e.Reason) + isQueryParam := strings.Contains(msg, "query parameter") || strings.Contains(reason, "query parameter") + isObjectSchema := strings.Contains(msg, "object") && strings.Contains(msg, "schema validation") || + strings.Contains(reason, "object") && strings.Contains(reason, "schema validation") + if isQueryParam && isObjectSchema { + continue + } + filtered = append(filtered, e) + } + return filtered +} + +// isRouteNotFound returns true if any validation error indicates path or operation not found. +func isRouteNotFound(errs []*validatorerrors.ValidationError) bool { + for _, e := range errs { + if e != nil && (e.IsPathMissingError() || e.IsOperationMissingError()) { + return true + } + } + return false +} + +// LibopenapiValidationErrors wraps libopenapi validation errors for use in hooks. +type LibopenapiValidationErrors struct { + Errors []*validatorerrors.ValidationError +} + +func (e *LibopenapiValidationErrors) Error() string { + if len(e.Errors) == 0 { + return "validation failed" + } + return e.Errors[0].Error() +} + +// ValidateRequest is the middleware to validate the request against the OpenAPI spec. +// Validation errors for multi-level nested deepObject query params are filtered out +// due to libopenapi-validator issue #83 (works for depth 1, fails for nested objects). +func ValidateRequest(v validator.Validator, opts ValidateRequestOption) func(h http.Handler) http.Handler { return func(h http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() skipServe := false - route, pathParams, err := validationRouter.FindRoute(r.WithContext(ctx)) - if err != nil { - if opts.RouteNotFoundHook != nil { - skipServe = opts.RouteNotFoundHook(err, w, r) - } - } else { - requestValidationInput := &openapi3filter.RequestValidationInput{ - Request: r, - PathParams: pathParams, - Route: route, - Options: opts.FilterOptions, + valid, validationErrors := v.ValidateHttpRequest(r) + if !valid { + // Filter out multi-level nested deepObject query param errors (issue #83). + validationErrors = filterQueryParamErrors(validationErrors) + if len(validationErrors) == 0 { + valid = true } - if err := openapi3filter.ValidateRequest(ctx, requestValidationInput); err != nil { - if opts.RouteValidationErrorHook != nil { - skipServe = opts.RouteValidationErrorHook(err, w, r) - } + } + if !valid { + err := &LibopenapiValidationErrors{Errors: validationErrors} + if opts.RouteNotFoundHook != nil && isRouteNotFound(validationErrors) { + skipServe = opts.RouteNotFoundHook(err, w, r) + } else if opts.RouteValidationErrorHook != nil { + skipServe = opts.RouteValidationErrorHook(err, w, r) } } if !skipServe { @@ -61,59 +155,27 @@ func ValidateRequest(validationRouter routers.Router, opts ValidateRequestOption } } -// ValidateResponseOption provides the hook function and the openapi3filter -// option to be passed in to the underlying library -type ValidateResponseOption struct { - // ResponseValidationErrorHook is called when the route response body is not validated - ResponseValidationErrorHook ResponseValidationFunc - // FilterOptions are the openapi3filter option to pass to the underlying lib - FilterOptions *openapi3filter.Options -} - -// ValidateResponse is the middleware to be used to validate the response to the spec -// passed in for the validation router -func ValidateResponse(validationRouter routers.Router, opts ValidateResponseOption) func(h http.Handler) http.Handler { +// ValidateResponse is the middleware to validate the response against the OpenAPI spec. +func ValidateResponse(v validator.Validator, opts ValidateResponseOption) func(h http.Handler) http.Handler { return func(h http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { - var err error - - route, pathParams, err := validationRouter.FindRoute(r) - - if err != nil { - h.ServeHTTP(w, r) - if opts.ResponseValidationErrorHook != nil { - opts.ResponseValidationErrorHook(err, r) - } - } else { - // need to wrap std lib response to access the body - rww := NewResponseWriterWrapper(w) + rww := NewResponseWriterWrapper(w) + h.ServeHTTP(rww, r) - h.ServeHTTP(rww, r) + b := new(bytes.Buffer) + if _, err := b.ReadFrom(rww.Body()); err != nil { + return + } - b := new(bytes.Buffer) - _, err := b.ReadFrom(rww.Body()) - if err != nil { - return - } - bodyReader := bytes.NewReader(b.Bytes()) - - responseValidationInput := &openapi3filter.ResponseValidationInput{ - RequestValidationInput: &openapi3filter.RequestValidationInput{ - Request: r, - PathParams: pathParams, - Route: route, - Options: opts.FilterOptions, - }, - Header: rww.Header(), - Body: io.NopCloser(bodyReader), - Status: *rww.StatusCode(), - } + resp := &http.Response{ + StatusCode: *rww.StatusCode(), + Header: rww.Header(), + Body: io.NopCloser(bytes.NewReader(b.Bytes())), + } - if err := openapi3filter.ValidateResponse(r.Context(), responseValidationInput); err != nil { - if opts.ResponseValidationErrorHook != nil { - opts.ResponseValidationErrorHook(err, r) - } - } + valid, validationErrors := v.ValidateHttpResponse(r, resp) + if !valid && opts.ResponseValidationErrorHook != nil { + opts.ResponseValidationErrorHook(&LibopenapiValidationErrors{Errors: validationErrors}, r) } } return http.HandlerFunc(fn) diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml index dff68d9d1b..6ef8e1aa9d 100644 --- a/api/v3/openapi.yaml +++ b/api/v3/openapi.yaml @@ -3747,15 +3747,18 @@ components: eq: type: string description: The field must match the provided value. - x-omitempty: true + oeq: + type: string + description: The field must match the provided value. This is an "override" filter that can be used when the default `eq` filter is not sufficient, e.g. when filtering by `model_name` where the name can have multiple variants for the same model ID. neq: type: string description: The field must not match the provided value. - x-omitempty: true contains: type: string description: The field must contain the provided value. - x-omitempty: true + ocontains: + type: string + description: The field must contain the provided value. This is an "override" filter that can be used when the default `contains` filter is not sufficient. description: |- A filter for a single string field. TODO: This is a temporary solution to support the filter API. @@ -3970,21 +3973,29 @@ components: type: object properties: provider: - allOf: + anyOf: + - type: string - $ref: '#/components/schemas/FilterSingleString' description: Filter by provider. e.g. ?filter[provider][eq]=openai + x-go-type: FilterString model_id: - allOf: + anyOf: + - type: string - $ref: '#/components/schemas/FilterSingleString' description: Filter by model ID. e.g. ?filter[model_id][eq]=gpt-4 + x-go-type: FilterString model_name: - allOf: + anyOf: + - type: string - $ref: '#/components/schemas/FilterSingleString' description: Filter by model name. e.g. ?filter[model_name][contains]=gpt + x-go-type: FilterString currency: - allOf: + anyOf: + - type: string - $ref: '#/components/schemas/FilterSingleString' description: Filter by currency code. e.g. ?filter[currency][eq]=USD + x-go-type: FilterString description: Filter options for listing LLM cost prices. Meter: type: object diff --git a/api/v3/request/aip.go b/api/v3/request/aip.go new file mode 100644 index 0000000000..bd5e0c8763 --- /dev/null +++ b/api/v3/request/aip.go @@ -0,0 +1,287 @@ +package request + +import ( + "net/http" + "strings" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/api/v3/filters" +) + +// GetAipAttributes returns the AipAttributes parsed from the request query string. +// If strict mode is enabled via WithAipStrictMode and an invalid parameter is +// encountered, it returns a *apierrors.BaseAPIError. +func GetAipAttributes(r *http.Request, opts ...AipParseOption) (*AipAttributes, error) { + a := &AipAttributes{} + + conf := newConfig() + lo.ForEach(opts, func(v AipParseOption, _ int) { v(conf) }) + + queryValues := r.URL.Query() + + pagination, err := extractPagination(r.Context(), queryValues, conf) + if err != nil { + return nil, err + } + a.Pagination = pagination + + filters, err := extractFilter(r.Context(), queryValues, conf) + if err != nil { + return nil, err + } + a.Filters = filters + + sort, sortErr := extractSort(queryValues, conf) + if sortErr != nil { + return nil, sortErr + } + + a.Sorts = sort + + return a, nil +} + +// RemapAipAttributes remaps the filters and sorts to another name +// this is used when API is not inlined with the database entities +func RemapAipAttributes(attrs *AipAttributes, mappedAttributes map[string]string) { + lo.ForEach(attrs.Filters, func(f QueryFilter, k int) { + attrs.Filters[k].Name = remapName(f.Name, mappedAttributes) + }) + lo.ForEach(attrs.Sorts, func(s SortBy, k int) { + attrs.Sorts[k].Field = remapName(s.Field, mappedAttributes) + }) +} + +// remapName remaps a field name using the provided mapping. +// Supports dot-notation: "labels.env" is remapped as "mapped_labels.env" if "labels" is in the map. +func remapName(name string, mappedAttributes map[string]string) string { + if mapped, ok := mappedAttributes[name]; ok { + return mapped + } + parts := strings.SplitN(name, ".", 2) // allow known_custom_field.unknown_key + mapped, ok := mappedAttributes[parts[0]] + if ok && len(parts) == 2 { + return mapped + "." + parts[1] + } + return name +} + +type AipAttributes struct { + Pagination Pagination + Filters []QueryFilter + Sorts []SortBy +} + +type paginationKind int + +const ( + paginationKindOffset paginationKind = iota + paginationKindCursor +) + +const ( + defaultPaginationMaxSize = 100 +) + +type config struct { + strictMode bool + defaultPageSize int + maxPageSize int + paginationKind paginationKind + cursorValidateUUIDs bool + cursorCipherKey string + defaultSort *defaultSort + authorizedFilters AuthorizedFilters + authorizedSorts []string + authorizedDotSorts []string +} + +func newConfig() *config { + return &config{ + maxPageSize: defaultPaginationMaxSize, + cursorValidateUUIDs: false, + cursorCipherKey: DefaultCipherKey, + strictMode: false, + defaultPageSize: DefaultPaginationSize, + paginationKind: paginationKindOffset, + } +} + +type AipParseOption func(*config) + +// WithAipStrictMode sets the parser a Strict, which means when some fallbackable +// arguments like page[size] or page[number] are invalid, the parser will return +// a 400 baseApiError instead of processing the request with default pagination size. +func WithAipStrictMode() AipParseOption { + return func(c *config) { + c.strictMode = true + } +} + +// WithCursorPagination sets the AIP request parser to only take the cursor AIP +// attributes in consideration and will ignore other kinds of paginations +func WithCursorPagination() AipParseOption { + return func(c *config) { + c.paginationKind = paginationKindCursor + } +} + +// WithOffsetPagination sets the AIP request parser to only take the offset AIP +// attributes (page[number], page[size]) in consideration and will ignore other +// kinds of paginations. +// +// This is the default parser behavior. +func WithOffsetPagination() AipParseOption { + return func(c *config) { + c.paginationKind = paginationKindOffset + } +} + +// WithDefaultPageSize sets the AIP request parser default page size. +// This value is used when the client is not setting the page[size] querystring +// or when the page[size] attribute is not valid and the parser is not using +// strict mode. Non-positive values are ignored. +// +// Default value is 20. +func WithDefaultPageSize(value int) AipParseOption { + return func(c *config) { + if value > 0 { + c.defaultPageSize = value + } + } +} + +// WithDefaultSort sets the default sort parameter if none is declared +// in the incoming request +func WithDefaultSort(field string, order SortOrder) AipParseOption { + return func(c *config) { + c.defaultSort = &defaultSort{ + field: field, + order: order, + } + } +} + +// WithCursorValidateUUIDs makes the AIP request parser to validate every UUID +// passed within a cursor in page[before] or page[after]. +func WithCursorValidateUUIDs() AipParseOption { + return func(c *config) { + c.cursorValidateUUIDs = true + } +} + +// WithCursorCipherKey sets the cipher key used with the cursor pagination encoding +// and decoding methods +// +// by default the aip request parser uses the request.DefaultCipherKey value +func WithCursorCipherKey(key string) AipParseOption { + return func(c *config) { + c.cursorCipherKey = key + } +} + +// WithAuthorizedFilters defines the set of filters that the parser should parse +// other filters are ignored +// +// by default the parser takes all the filters that are passed to it +// Use the DotFilter parameter for filters that have unknown sub-attributes (filters[labels.key_1]=true) +func WithAuthorizedFilters(fields map[string]AIPFilterOption) AipParseOption { + return func(c *config) { + c.authorizedFilters = fields + } +} + +// WithAuthorizedSorts defines the set of allowed sort fields. Sorts on any +// field not in the provided list are ignored. +// +// By default the parser accepts all sort fields. +// Do not use dot notation (field.subfield) with this method; use WithAuthorizedDotSorts instead. +func WithAuthorizedSorts(fields []string) AipParseOption { + return func(c *config) { + c.authorizedSorts = fields + } +} + +// WithAuthorizedDotSorts is equivalent to WithAuthorizedSorts but allows +// sorting on user-defined sub-attributes. +// +// examples: +// "foo" allows ?sort=foo.bar or ?sort=foo.baz. +// "foo.bar" only allows ?sort=foo.bar. +// "foo" rejects ?sort=foo because it doesn't have a sub-attribute. +func WithAuthorizedDotSorts(fields []string) AipParseOption { + return func(c *config) { + c.authorizedDotSorts = fields + } +} + +// WithMaxPageSize defines the maximum size of the pagination. +// Non-positive values are ignored. +// +// Default value is 100. +func WithMaxPageSize(size int) AipParseOption { + return func(c *config) { + if size > 0 { + c.maxPageSize = size + } + } +} + +// ValidationFunc is a field-level validation callback. A non-nil error return +// indicates validation failure; the error message is included in the API error response. +type ValidationFunc func(field, value string) error + +// AuthorizedFilters reprensents the map of fields that are authorized to be +// filtered on +type AuthorizedFilters map[string]AIPFilterOption + +// AIPFilterOption defines the list of available filters for a giving field +// and its optional validation function +type AIPFilterOption struct { + Filters []QueryFilterOp + ValidationFunc ValidationFunc + DotFilter bool +} + +// FilterStringFromAip extracts a *filters.StringFilter for the named field from AIP query filters. +// Returns nil if no matching filters are found. +func FilterStringFromAip(queryFilters []QueryFilter, field string) *filters.StringFilter { + matching := lo.Filter(queryFilters, func(qf QueryFilter, _ int) bool { return qf.Name == field }) + if len(matching) == 0 { + return nil + } + + var f filters.StringFilter + lo.ForEach(matching, func(qf QueryFilter, _ int) { + v := qf.Value + switch qf.Filter { + case QueryFilterEQ: + f.Eq = &v + case QueryFilterNEQ: + f.Neq = &v + case QueryFilterGT: + f.Gt = &v + case QueryFilterGTE: + f.Gte = &v + case QueryFilterLT: + f.Lt = &v + case QueryFilterLTE: + f.Lte = &v + case QueryFilterContains: + f.Contains = &v + case QueryFilterOrEQ: + f.Oeq = &v + case QueryFilterOrContains: + f.Ocontains = &v + case QueryFilterExists: + t := true + f.Exists = &t + } + }) + + if f.IsEmpty() { + return nil + } + return &f +} diff --git a/api/v3/request/aip_filter.go b/api/v3/request/aip_filter.go new file mode 100644 index 0000000000..e9c33f720a --- /dev/null +++ b/api/v3/request/aip_filter.go @@ -0,0 +1,252 @@ +package request + +import ( + "context" + "errors" + "fmt" + "net/url" + "slices" + "strings" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/api/v3/apierrors" +) + +// ErrReturnEmptySet this error is used as underlying error as a signal to HandleAPIError that it should return an empty set. +var ErrReturnEmptySet = errors.New("should return an empty set") + +var ( + filterMap = map[string]QueryFilterOp{ + "oeq": QueryFilterOrEQ, + "eq": QueryFilterEQ, + "neq": QueryFilterNEQ, + "gt": QueryFilterGT, + "gte": QueryFilterGTE, + "lt": QueryFilterLT, + "lte": QueryFilterLTE, + "contains": QueryFilterContains, + "ocontains": QueryFilterOrContains, + "exists": QueryFilterExists, + } + ErrUnallowedFilterColumn = errors.New("unallowed filtering column") + ErrUnallowedFilterMethod = errors.New("unallowed filtering method") +) + +func filterName(value QueryFilterOp) string { + key, _ := lo.FindKey(filterMap, value) + return key +} + +const ( + FilterQuery = "filter" + + // filter[field][eq] + QueryFilterEQ QueryFilterOp = iota + // filter[field][neq] + QueryFilterNEQ + // filter[field][gt] + QueryFilterGT + // filter[field][gte] + QueryFilterGTE + // filter[field][lt] + QueryFilterLT + // filter[field][lte] + QueryFilterLTE + // filter[field][contains] + QueryFilterContains + // filter[field] + QueryFilterExists + // filter[field][oeq] + QueryFilterOrEQ + // filter[field][ocontains] + QueryFilterOrContains +) + +// lookup to only focus filter[foo] and not filterfoo[bar] +var prefixLookup = FilterQuery + "[" + +// QueryFilter column filter +type QueryFilter struct { + Name string + Path *string + Value string + Values []string + Filter QueryFilterOp +} + +type QueryFilterOp int + +func extractFilter(ctx context.Context, qs url.Values, c *config) ([]QueryFilter, *apierrors.BaseAPIError) { + type filterEntry struct{ key, value string } + + entries := lo.FlatMap(lo.Entries(qs), func(e lo.Entry[string, []string], _ int) []filterEntry { + if !strings.HasPrefix(e.Key, prefixLookup) { + return nil + } + return lo.Map(e.Value, func(v string, _ int) filterEntry { return filterEntry{e.Key, v} }) + }) + + var out []QueryFilter + for _, e := range entries { + qf, skip, err := processFilter(ctx, e.value, e.key, c) + if err != nil { + return nil, err + } + if !skip { + out = append(out, qf) + } + } + return out, nil +} + +func processFilter(ctx context.Context, filter, key string, c *config) (QueryFilter, bool, *apierrors.BaseAPIError) { + o, err := parseFilterQs(ctx, filter, key) + if err != nil { + return QueryFilter{}, false, err + } + if o.Name == "" { + return QueryFilter{}, true, nil + } + if filter == "" { + o.Filter = QueryFilterExists + } + o.Value = filter + + skip, apiErr := checkFilterAuthorization(ctx, o, filter, c) + if apiErr != nil { + return QueryFilter{}, false, apiErr + } + if skip { + return QueryFilter{}, true, nil + } + + if o.Filter == QueryFilterOrEQ || o.Filter == QueryFilterOrContains { + o.Values = parseMultipleStringValues(o.Value) + } + return o, false, nil +} + +func resolveAuthorizedFilter(name string, authorizedFilters AuthorizedFilters) (AIPFilterOption, bool) { + if !strings.ContainsRune(name, '.') { + opt, ok := authorizedFilters[name] + return opt, ok && !opt.DotFilter + } + parts := strings.SplitN(name, ".", 2) // allow filters[known_custom_field.unknown_key] + if opt, ok := authorizedFilters[parts[0]]; ok && opt.DotFilter { + return opt, true + } + opt, ok := authorizedFilters[name] // specific case where only 1 field is allowed + return opt, ok && opt.DotFilter +} + +func checkFilterAuthorization(ctx context.Context, o QueryFilter, rawFilter string, c *config) (bool, *apierrors.BaseAPIError) { + if c.authorizedFilters == nil { + return false, nil + } + + authorizedOpt, ok := resolveAuthorizedFilter(o.Name, c.authorizedFilters) + if !ok && c.strictMode { + return false, apierrors.NewBadRequestError(ctx, ErrUnallowedFilterMethod, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: o.Name, + Reason: "unauthorized filter", + Source: apierrors.InvalidParamSourceQuery, + Rule: "unknown_property", + }, + }) + } + if !ok { + return true, nil + } + + filterAllowed := slices.Contains(authorizedOpt.Filters, o.Filter) + if !filterAllowed && c.strictMode { + return false, apierrors.NewBadRequestError(ctx, ErrUnallowedFilterColumn, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: filterName(o.Filter), + Reason: "unauthorized filter on column", + Source: apierrors.InvalidParamSourceQuery, + Rule: "unknown_property", + }, + }) + } + if !filterAllowed { + return true, nil + } + + if authorizedOpt.ValidationFunc == nil { + return false, nil + } + + err := authorizedOpt.ValidationFunc(o.Name, o.Value) + if err == nil { + return false, nil + } + if errors.Is(err, ErrReturnEmptySet) { + // for errors in uuid format, we want to handle it by returning an empty list. + return false, apierrors.NewEmptySetResponse(ctx, c.paginationKind == paginationKindCursor) + } + return false, apierrors.NewBadRequestError(ctx, ErrUnallowedFilterColumn, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: rawFilter, + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + Rule: "unauthorized filter on column", + }, + }) +} + +func parseMultipleStringValues(strValue string) []string { + return lo.Map(strings.Split(strValue, ","), func(v string, _ int) string { return strings.TrimSpace(v) }) +} + +func parseFilterQs(ctx context.Context, filter, qs string) (QueryFilter, *apierrors.BaseAPIError) { + o := QueryFilter{} + i := strings.IndexRune(qs, '[') + if i == -1 { + return o, nil + } + + endFirst := strings.IndexRune(qs, ']') + if endFirst == -1 { + return o, nil + } + o.Filter = QueryFilterEQ + o.Name = qs[i+1 : endFirst] + + qsRest := qs[endFirst+1:] + if len(qsRest) == 0 { + return o, nil + } + + start := strings.IndexRune(qsRest, '[') + end := strings.IndexRune(qsRest, ']') + if start == -1 || end == -1 || end <= start { + return o, nil + } + + op := qsRest[start+1 : end] + if len(op) == 0 { + return o, nil + } + + queryOp, ok := filterMap[op] + if !ok { + return QueryFilter{}, apierrors.NewBadRequestError(ctx, ErrUnallowedFilterColumn, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: filter, + Reason: fmt.Sprintf("invalid operation '%s' on filter", op), + Source: apierrors.InvalidParamSourceQuery, + Rule: "unauthorized filter on column", + }, + }) + } + + o.Filter = queryOp + return o, nil +} diff --git a/api/v3/request/aip_filter_test.go b/api/v3/request/aip_filter_test.go new file mode 100644 index 0000000000..a2c13d494d --- /dev/null +++ b/api/v3/request/aip_filter_test.go @@ -0,0 +1,270 @@ +package request_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" +) + +// newFilterRequest builds an HTTP GET request with the given query params. +func newFilterRequest(t *testing.T, params url.Values) *http.Request { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.URL.RawQuery = params.Encode() + return r +} + +func TestExtractFilter_NoParams(t *testing.T) { + t.Run("no query params returns empty filters", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + attrs, err := request.GetAipAttributes(r) + require.NoError(t, err) + assert.Empty(t, attrs.Filters) + }) + + t.Run("non-filter params do not produce filters", func(t *testing.T) { + params := url.Values{"page[size]": {"10"}, "sort": {"name"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.NoError(t, err) + assert.Empty(t, attrs.Filters) + }) + + t.Run("filter prefix without brackets is ignored", func(t *testing.T) { + params := url.Values{"filtername": {"foo"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.NoError(t, err) + assert.Empty(t, attrs.Filters) + }) +} + +func TestExtractFilter_Operations(t *testing.T) { + cases := []struct { + op string + param string + wantFilter request.QueryFilterOp + }{ + {"eq", "filter[name][eq]", request.QueryFilterEQ}, + {"neq", "filter[name][neq]", request.QueryFilterNEQ}, + {"gt", "filter[name][gt]", request.QueryFilterGT}, + {"gte", "filter[name][gte]", request.QueryFilterGTE}, + {"lt", "filter[name][lt]", request.QueryFilterLT}, + {"lte", "filter[name][lte]", request.QueryFilterLTE}, + {"contains", "filter[name][contains]", request.QueryFilterContains}, + {"oeq", "filter[name][oeq]", request.QueryFilterOrEQ}, + {"ocontains", "filter[name][ocontains]", request.QueryFilterOrContains}, + } + + for _, tc := range cases { + t.Run("parses "+tc.op+" operator", func(t *testing.T) { + params := url.Values{tc.param: {"testvalue"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.NoError(t, err) + require.Len(t, attrs.Filters, 1) + assert.Equal(t, "name", attrs.Filters[0].Name) + assert.Equal(t, "testvalue", attrs.Filters[0].Value) + assert.Equal(t, tc.wantFilter, attrs.Filters[0].Filter) + }) + } + + t.Run("no op bracket defaults to eq", func(t *testing.T) { + params := url.Values{"filter[name]": {"testvalue"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.NoError(t, err) + require.Len(t, attrs.Filters, 1) + assert.Equal(t, request.QueryFilterEQ, attrs.Filters[0].Filter) + }) + + t.Run("empty value on plain filter is exists", func(t *testing.T) { + params := url.Values{"filter[name]": {""}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.NoError(t, err) + require.Len(t, attrs.Filters, 1) + assert.Equal(t, request.QueryFilterExists, attrs.Filters[0].Filter) + }) + + t.Run("explicit exists operator", func(t *testing.T) { + params := url.Values{"filter[name][exists]": {"1"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.NoError(t, err) + require.Len(t, attrs.Filters, 1) + assert.Equal(t, request.QueryFilterExists, attrs.Filters[0].Filter) + }) + + t.Run("invalid operation returns error", func(t *testing.T) { + params := url.Values{"filter[name][bogus]": {"foo"}} + _, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.Error(t, err) + }) +} + +func TestExtractFilter_MultiValueOps(t *testing.T) { + t.Run("oeq splits comma-separated values", func(t *testing.T) { + params := url.Values{"filter[name][oeq]": {"a,b,c"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.NoError(t, err) + require.Len(t, attrs.Filters, 1) + assert.Equal(t, []string{"a", "b", "c"}, attrs.Filters[0].Values) + }) + + t.Run("oeq trims whitespace from values", func(t *testing.T) { + params := url.Values{"filter[name][oeq]": {"a, b , c"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.NoError(t, err) + require.Len(t, attrs.Filters, 1) + assert.Equal(t, []string{"a", "b", "c"}, attrs.Filters[0].Values) + }) + + t.Run("ocontains splits comma-separated values", func(t *testing.T) { + params := url.Values{"filter[name][ocontains]": {"foo,bar"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.NoError(t, err) + require.Len(t, attrs.Filters, 1) + assert.Equal(t, []string{"foo", "bar"}, attrs.Filters[0].Values) + }) +} + +func TestExtractFilter_MultipleFilters(t *testing.T) { + t.Run("multiple different fields", func(t *testing.T) { + params := url.Values{ + "filter[name][eq]": {"foo"}, + "filter[status][eq]": {"active"}, + } + attrs, err := request.GetAipAttributes(newFilterRequest(t, params)) + require.NoError(t, err) + assert.Len(t, attrs.Filters, 2) + }) +} + +func TestExtractFilter_AuthorizedFilters(t *testing.T) { + authorized := request.AuthorizedFilters{ + "name": {Filters: []request.QueryFilterOp{request.QueryFilterEQ, request.QueryFilterContains}}, + } + + t.Run("authorized field and op passes through", func(t *testing.T) { + params := url.Values{"filter[name][eq]": {"foo"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params), request.WithAuthorizedFilters(authorized)) + require.NoError(t, err) + require.Len(t, attrs.Filters, 1) + assert.Equal(t, "name", attrs.Filters[0].Name) + }) + + t.Run("unauthorized field silently ignored in non-strict mode", func(t *testing.T) { + params := url.Values{"filter[unknown][eq]": {"foo"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params), request.WithAuthorizedFilters(authorized)) + require.NoError(t, err) + assert.Empty(t, attrs.Filters) + }) + + t.Run("unauthorized field returns 400 in strict mode", func(t *testing.T) { + params := url.Values{"filter[unknown][eq]": {"foo"}} + _, err := request.GetAipAttributes(newFilterRequest(t, params), + request.WithAuthorizedFilters(authorized), + request.WithAipStrictMode(), + ) + require.Error(t, err) + var apiErr *apierrors.BaseAPIError + require.True(t, errors.As(err, &apiErr)) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + }) + + t.Run("unauthorized op silently ignored in non-strict mode", func(t *testing.T) { + params := url.Values{"filter[name][gt]": {"foo"}} // gt not in authorized ops + attrs, err := request.GetAipAttributes(newFilterRequest(t, params), request.WithAuthorizedFilters(authorized)) + require.NoError(t, err) + assert.Empty(t, attrs.Filters) + }) + + t.Run("unauthorized op returns 400 in strict mode", func(t *testing.T) { + params := url.Values{"filter[name][gt]": {"foo"}} + _, err := request.GetAipAttributes(newFilterRequest(t, params), + request.WithAuthorizedFilters(authorized), + request.WithAipStrictMode(), + ) + require.Error(t, err) + var apiErr *apierrors.BaseAPIError + require.True(t, errors.As(err, &apiErr)) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + }) +} + +func TestExtractFilter_DotFilters(t *testing.T) { + authorized := request.AuthorizedFilters{ + "labels": { + Filters: []request.QueryFilterOp{request.QueryFilterEQ}, + DotFilter: true, + }, + } + + t.Run("dot sub-attribute passes for DotFilter field", func(t *testing.T) { + params := url.Values{"filter[labels.env][eq]": {"prod"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params), request.WithAuthorizedFilters(authorized)) + require.NoError(t, err) + require.Len(t, attrs.Filters, 1) + assert.Equal(t, "labels.env", attrs.Filters[0].Name) + assert.Equal(t, "prod", attrs.Filters[0].Value) + }) + + t.Run("bare field name rejected for DotFilter-only field", func(t *testing.T) { + params := url.Values{"filter[labels][eq]": {"prod"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params), request.WithAuthorizedFilters(authorized)) + require.NoError(t, err) + assert.Empty(t, attrs.Filters) + }) +} + +func TestExtractFilter_ValidationFunc(t *testing.T) { + errCustom := errors.New("custom validation error") + authorized := request.AuthorizedFilters{ + "id": { + Filters: []request.QueryFilterOp{request.QueryFilterEQ}, + ValidationFunc: func(_, value string) error { + if value == "bad" { + return errCustom + } + return nil + }, + }, + "uuid_id": { + Filters: []request.QueryFilterOp{request.QueryFilterEQ}, + ValidationFunc: func(_, value string) error { + if value == "not-a-uuid" { + return request.ErrReturnEmptySet + } + return nil + }, + }, + } + + t.Run("valid value passes through", func(t *testing.T) { + params := url.Values{"filter[id][eq]": {"good-value"}} + attrs, err := request.GetAipAttributes(newFilterRequest(t, params), request.WithAuthorizedFilters(authorized)) + require.NoError(t, err) + require.Len(t, attrs.Filters, 1) + }) + + t.Run("validation error returns 400", func(t *testing.T) { + params := url.Values{"filter[id][eq]": {"bad"}} + _, err := request.GetAipAttributes(newFilterRequest(t, params), request.WithAuthorizedFilters(authorized)) + require.Error(t, err) + var apiErr *apierrors.BaseAPIError + require.True(t, errors.As(err, &apiErr)) + assert.Equal(t, http.StatusBadRequest, apiErr.Status) + }) + + t.Run("ErrReturnEmptySet returns 200 empty-set response", func(t *testing.T) { + params := url.Values{"filter[uuid_id][eq]": {"not-a-uuid"}} + _, err := request.GetAipAttributes(newFilterRequest(t, params), request.WithAuthorizedFilters(authorized)) + require.Error(t, err) + var apiErr *apierrors.BaseAPIError + require.True(t, errors.As(err, &apiErr)) + assert.Equal(t, http.StatusOK, apiErr.Status) + }) +} diff --git a/api/v3/request/aip_pagination.go b/api/v3/request/aip_pagination.go new file mode 100644 index 0000000000..a1497b0bbd --- /dev/null +++ b/api/v3/request/aip_pagination.go @@ -0,0 +1,162 @@ +package request + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + + "github.com/openmeterio/openmeter/api/v3/apierrors" +) + +const ( + PageNumberQuery = "page[number]" + + // offset pagination specific + PageSizeQuery = "page[size]" + + // cursor pagination specific + PageBeforeQuery = "page[before]" + PageAfterQuery = "page[after]" + + DefaultPaginationNumber = 1 + DefaultPaginationSize = 20 +) + +var ( + ErrCursorUndefined = errors.New("at least before or after cursor need to be defined") + ErrCursorRange = errors.New("range pagination not supported, both before and after cursor were defined") +) + +type Pagination struct { + Size int + Number int + Offset int + Limit int + After *Cursor + Before *Cursor +} + +func extractPagination(ctx context.Context, qs url.Values, c *config) (Pagination, *apierrors.BaseAPIError) { + size, apiErr := parsePageSize(ctx, qs, c) + if apiErr != nil { + return Pagination{}, apiErr + } + + p := Pagination{Size: min(size, c.maxPageSize)} + + switch c.paginationKind { + case paginationKindOffset: + number, apiErr := parsePageNumber(ctx, qs, c) + if apiErr != nil { + return Pagination{}, apiErr + } + p.Number = number + coef := max(p.Number-1, 0) + p.Offset = coef * p.Size + p.Limit = p.Size + + case paginationKindCursor: + if qs.Has(PageBeforeQuery) && qs.Has(PageAfterQuery) { + return Pagination{}, apierrors.NewBadRequestError(ctx, ErrCursorRange, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Source: apierrors.InvalidParamSourceQuery, + Reason: "api doesn't support range pagination", + }, + }) + } + + before, apiErr := parseCursorParam(ctx, qs, PageBeforeQuery, c.cursorCipherKey, c.cursorValidateUUIDs) + if apiErr != nil { + return Pagination{}, apiErr + } + p.Before = before + + after, apiErr := parseCursorParam(ctx, qs, PageAfterQuery, c.cursorCipherKey, c.cursorValidateUUIDs) + if apiErr != nil { + return Pagination{}, apiErr + } + p.After = after + } + + return p, nil +} + +func parsePageSize(ctx context.Context, qs url.Values, c *config) (int, *apierrors.BaseAPIError) { + if !qs.Has(PageSizeQuery) { + return c.defaultPageSize, nil + } + + pageSize, err := strconv.ParseInt(qs.Get(PageSizeQuery), 10, 16) + if err != nil && (c.strictMode || pageSize < 0) { + return 0, apierrors.NewBadRequestError(ctx, err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: PageSizeQuery, + Reason: "unable to parse query field", + Source: apierrors.InvalidParamSourceQuery, + Rule: "page size should be a positive integer", + }, + }) + } + if err != nil { + return c.defaultPageSize, nil + } + if pageSize < 1 { + return DefaultPaginationSize, nil + } + return int(pageSize), nil +} + +func parsePageNumber(ctx context.Context, qs url.Values, c *config) (int, *apierrors.BaseAPIError) { + if !qs.Has(PageNumberQuery) { + return DefaultPaginationNumber, nil + } + + pageNumber, err := strconv.ParseInt(qs.Get(PageNumberQuery), 10, 16) + if err != nil && c.strictMode { + return 0, apierrors.NewBadRequestError(ctx, err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: PageNumberQuery, + Reason: "unable to parse query field", + Source: apierrors.InvalidParamSourceQuery, + Rule: "page number should be a positive integer", + }, + }) + } + if err == nil && pageNumber < 0 { + return 0, apierrors.NewBadRequestError(ctx, err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: PageNumberQuery, + Reason: "unable to parse query field", + Source: apierrors.InvalidParamSourceQuery, + Rule: "page number should be a positive integer", + }, + }) + } + if err != nil || pageNumber < 1 { + return DefaultPaginationNumber, nil + } + return int(pageNumber), nil +} + +func parseCursorParam(ctx context.Context, qs url.Values, key, cipherKey string, validateUUIDs bool) (*Cursor, *apierrors.BaseAPIError) { + if !qs.Has(key) { + return nil, nil + } + cursor, err := decodeCursorAfterQueryUnescape(cipherKey, qs.Get(key), validateUUIDs) + if err != nil { + return nil, apierrors.NewBadRequestError(ctx, err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Source: apierrors.InvalidParamSourceQuery, + Reason: fmt.Sprintf("unable to parse %s cursor", key), + }, + }) + } + return cursor, nil +} diff --git a/api/v3/request/aip_sort.go b/api/v3/request/aip_sort.go new file mode 100644 index 0000000000..a007afdea7 --- /dev/null +++ b/api/v3/request/aip_sort.go @@ -0,0 +1,57 @@ +package request + +import ( + "net/url" + "slices" + "strings" + + "github.com/samber/lo" +) + +const SortQuery = "sort" + +type defaultSort struct { + field string + order SortOrder +} + +func extractSort(qs url.Values, c *config) ([]SortBy, error) { + if !qs.Has(SortQuery) { + if c.defaultSort == nil { + return nil, nil + } + return []SortBy{{Field: c.defaultSort.field, Order: c.defaultSort.order}}, nil + } + + segments := strings.Split(qs.Get(SortQuery), ",") + out := lo.FilterMap(segments, func(v string, _ int) (SortBy, bool) { + parts := strings.Fields(strings.TrimSpace(v)) + if len(parts) == 0 { + return SortBy{}, false + } + sortBy := SortBy{Field: parts[0], Order: SortOrderAsc} + if len(parts) > 1 { + order := SortOrder(parts[1]) + if order == SortOrderAsc || order == SortOrderDesc { + sortBy.Order = order + } + } + return sortBy, isAuthorizedSort(sortBy.Field, c) + }) + return out, nil +} + +func isAuthorizedSort(field string, c *config) bool { + checkSorts := len(c.authorizedSorts) != 0 + checkDotSorts := len(c.authorizedDotSorts) != 0 + switch { + case !checkDotSorts && !checkSorts: + return true + case checkDotSorts && strings.ContainsRune(field, '.'): + parts := strings.SplitN(field, ".", 2) + return slices.Contains(c.authorizedDotSorts, parts[0]) || slices.Contains(c.authorizedDotSorts, field) + case checkSorts: + return slices.Contains(c.authorizedSorts, field) + } + return false +} diff --git a/api/v3/request/aip_sort_test.go b/api/v3/request/aip_sort_test.go new file mode 100644 index 0000000000..7e0ec1ff62 --- /dev/null +++ b/api/v3/request/aip_sort_test.go @@ -0,0 +1,167 @@ +package request_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/api/v3/request" +) + +func newSortRequest(t *testing.T, sortValue string) *http.Request { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.URL.RawQuery = url.Values{"sort": {sortValue}}.Encode() + return r +} + +func TestExtractSort_NoParam(t *testing.T) { + t.Run("no sort param returns nil", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + attrs, err := request.GetAipAttributes(r) + require.NoError(t, err) + assert.Nil(t, attrs.Sorts) + }) + + t.Run("no sort param with default sort returns default", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + attrs, err := request.GetAipAttributes(r, request.WithDefaultSort("created_at", request.SortOrderDesc)) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 1) + assert.Equal(t, "created_at", attrs.Sorts[0].Field) + assert.Equal(t, request.SortOrderDesc, attrs.Sorts[0].Order) + }) + + t.Run("explicit sort param overrides default", func(t *testing.T) { + attrs, err := request.GetAipAttributes( + newSortRequest(t, "name"), + request.WithDefaultSort("created_at", request.SortOrderDesc), + ) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 1) + assert.Equal(t, "name", attrs.Sorts[0].Field) + }) +} + +func TestExtractSort_SingleField(t *testing.T) { + t.Run("field only defaults to asc", func(t *testing.T) { + attrs, err := request.GetAipAttributes(newSortRequest(t, "name")) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 1) + assert.Equal(t, "name", attrs.Sorts[0].Field) + assert.Equal(t, request.SortOrderAsc, attrs.Sorts[0].Order) + }) + + t.Run("field with asc order", func(t *testing.T) { + attrs, err := request.GetAipAttributes(newSortRequest(t, "name asc")) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 1) + assert.Equal(t, request.SortOrderAsc, attrs.Sorts[0].Order) + }) + + t.Run("field with desc order", func(t *testing.T) { + attrs, err := request.GetAipAttributes(newSortRequest(t, "name desc")) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 1) + assert.Equal(t, "name", attrs.Sorts[0].Field) + assert.Equal(t, request.SortOrderDesc, attrs.Sorts[0].Order) + }) + + t.Run("unknown order string defaults to asc", func(t *testing.T) { + attrs, err := request.GetAipAttributes(newSortRequest(t, "name invalid_order")) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 1) + assert.Equal(t, request.SortOrderAsc, attrs.Sorts[0].Order) + }) +} + +func TestExtractSort_MultipleFields(t *testing.T) { + t.Run("two comma-separated fields", func(t *testing.T) { + attrs, err := request.GetAipAttributes(newSortRequest(t, "name,created_at")) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 2) + assert.Equal(t, "name", attrs.Sorts[0].Field) + assert.Equal(t, request.SortOrderAsc, attrs.Sorts[0].Order) + assert.Equal(t, "created_at", attrs.Sorts[1].Field) + assert.Equal(t, request.SortOrderAsc, attrs.Sorts[1].Order) + }) + + t.Run("mixed order on multiple fields", func(t *testing.T) { + attrs, err := request.GetAipAttributes(newSortRequest(t, "name asc,created_at desc")) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 2) + assert.Equal(t, request.SortOrderAsc, attrs.Sorts[0].Order) + assert.Equal(t, request.SortOrderDesc, attrs.Sorts[1].Order) + }) + + t.Run("empty segment in comma list is ignored", func(t *testing.T) { + attrs, err := request.GetAipAttributes(newSortRequest(t, "name,,created_at")) + require.NoError(t, err) + assert.Len(t, attrs.Sorts, 2) + }) +} + +func TestExtractSort_AuthorizedSorts(t *testing.T) { + t.Run("authorized field passes through", func(t *testing.T) { + attrs, err := request.GetAipAttributes( + newSortRequest(t, "name"), + request.WithAuthorizedSorts([]string{"name", "created_at"}), + ) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 1) + assert.Equal(t, "name", attrs.Sorts[0].Field) + }) + + t.Run("unauthorized field is silently ignored", func(t *testing.T) { + attrs, err := request.GetAipAttributes( + newSortRequest(t, "unknown_field"), + request.WithAuthorizedSorts([]string{"name"}), + ) + require.NoError(t, err) + assert.Empty(t, attrs.Sorts) + }) + + t.Run("only authorized fields pass through from multi-field sort", func(t *testing.T) { + attrs, err := request.GetAipAttributes( + newSortRequest(t, "name,unknown_field"), + request.WithAuthorizedSorts([]string{"name"}), + ) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 1) + assert.Equal(t, "name", attrs.Sorts[0].Field) + }) +} + +func TestExtractSort_AuthorizedDotSorts(t *testing.T) { + t.Run("dot sub-attribute authorized by prefix", func(t *testing.T) { + attrs, err := request.GetAipAttributes( + newSortRequest(t, "labels.env"), + request.WithAuthorizedDotSorts([]string{"labels"}), + ) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 1) + assert.Equal(t, "labels.env", attrs.Sorts[0].Field) + }) + + t.Run("exact dot field authorized", func(t *testing.T) { + attrs, err := request.GetAipAttributes( + newSortRequest(t, "labels.env"), + request.WithAuthorizedDotSorts([]string{"labels.env"}), + ) + require.NoError(t, err) + require.Len(t, attrs.Sorts, 1) + }) + + t.Run("unauthorized dot prefix is ignored", func(t *testing.T) { + attrs, err := request.GetAipAttributes( + newSortRequest(t, "other.key"), + request.WithAuthorizedDotSorts([]string{"labels"}), + ) + require.NoError(t, err) + assert.Empty(t, attrs.Sorts) + }) +} diff --git a/api/v3/request/aip_test.go b/api/v3/request/aip_test.go new file mode 100644 index 0000000000..bfcf85b170 --- /dev/null +++ b/api/v3/request/aip_test.go @@ -0,0 +1,251 @@ +package request_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/api/v3/request" +) + +func TestGetAipAttributes_Pagination(t *testing.T) { + t.Run("defaults applied when no params", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + attrs, err := request.GetAipAttributes(r) + require.NoError(t, err) + assert.Equal(t, request.DefaultPaginationSize, attrs.Pagination.Size) + assert.Equal(t, request.DefaultPaginationNumber, attrs.Pagination.Number) + }) + + t.Run("page size and number parsed", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/?page%5Bsize%5D=5&page%5Bnumber%5D=3", nil) + attrs, err := request.GetAipAttributes(r) + require.NoError(t, err) + assert.Equal(t, 5, attrs.Pagination.Size) + assert.Equal(t, 3, attrs.Pagination.Number) + }) + + t.Run("page size capped at max", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/?page%5Bsize%5D=999", nil) + attrs, err := request.GetAipAttributes(r, request.WithMaxPageSize(50)) + require.NoError(t, err) + assert.Equal(t, 50, attrs.Pagination.Size) + }) +} + +func TestGetAipAttributes_Combined(t *testing.T) { + t.Run("pagination filter and sort all parsed together", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + q := r.URL.Query() + q.Set("page[size]", "5") + q.Set("page[number]", "2") + q.Set("filter[name][eq]", "foo") + q.Set("sort", "name desc") + r.URL.RawQuery = q.Encode() + + attrs, err := request.GetAipAttributes(r) + require.NoError(t, err) + assert.Equal(t, 5, attrs.Pagination.Size) + assert.Equal(t, 2, attrs.Pagination.Number) + require.Len(t, attrs.Filters, 1) + assert.Equal(t, "name", attrs.Filters[0].Name) + require.Len(t, attrs.Sorts, 1) + assert.Equal(t, "name", attrs.Sorts[0].Field) + assert.Equal(t, request.SortOrderDesc, attrs.Sorts[0].Order) + }) +} + +func TestRemapAipAttributes(t *testing.T) { + t.Run("remaps a filter field name", func(t *testing.T) { + attrs := &request.AipAttributes{ + Filters: []request.QueryFilter{ + {Name: "api_name", Value: "foo", Filter: request.QueryFilterEQ}, + }, + } + request.RemapAipAttributes(attrs, map[string]string{"api_name": "db_name"}) + assert.Equal(t, "db_name", attrs.Filters[0].Name) + }) + + t.Run("unmapped filter field is unchanged", func(t *testing.T) { + attrs := &request.AipAttributes{ + Filters: []request.QueryFilter{ + {Name: "other", Value: "foo", Filter: request.QueryFilterEQ}, + }, + } + request.RemapAipAttributes(attrs, map[string]string{"api_name": "db_name"}) + assert.Equal(t, "other", attrs.Filters[0].Name) + }) + + t.Run("remaps dot filter preserving sub-attribute", func(t *testing.T) { + attrs := &request.AipAttributes{ + Filters: []request.QueryFilter{ + {Name: "labels.env", Value: "prod", Filter: request.QueryFilterEQ}, + }, + } + request.RemapAipAttributes(attrs, map[string]string{"labels": "metadata"}) + assert.Equal(t, "metadata.env", attrs.Filters[0].Name) + }) + + t.Run("remaps a sort field name", func(t *testing.T) { + attrs := &request.AipAttributes{ + Sorts: []request.SortBy{ + {Field: "api_name", Order: request.SortOrderAsc}, + }, + } + request.RemapAipAttributes(attrs, map[string]string{"api_name": "db_name"}) + assert.Equal(t, "db_name", attrs.Sorts[0].Field) + }) + + t.Run("unmapped sort field is unchanged", func(t *testing.T) { + attrs := &request.AipAttributes{ + Sorts: []request.SortBy{ + {Field: "other", Order: request.SortOrderAsc}, + }, + } + request.RemapAipAttributes(attrs, map[string]string{"api_name": "db_name"}) + assert.Equal(t, "other", attrs.Sorts[0].Field) + }) + + t.Run("remaps dot sort preserving sub-attribute", func(t *testing.T) { + attrs := &request.AipAttributes{ + Sorts: []request.SortBy{ + {Field: "labels.env", Order: request.SortOrderDesc}, + }, + } + request.RemapAipAttributes(attrs, map[string]string{"labels": "metadata"}) + assert.Equal(t, "metadata.env", attrs.Sorts[0].Field) + }) + + t.Run("nil filters and sorts are no-ops", func(t *testing.T) { + attrs := &request.AipAttributes{} + request.RemapAipAttributes(attrs, map[string]string{"api_name": "db_name"}) + assert.Nil(t, attrs.Filters) + assert.Nil(t, attrs.Sorts) + }) +} + +func TestFilterStringFromAip(t *testing.T) { + t.Run("returns nil when filter list is empty", func(t *testing.T) { + assert.Nil(t, request.FilterStringFromAip(nil, "name")) + }) + + t.Run("returns nil when no filter matches the field", func(t *testing.T) { + f := request.FilterStringFromAip([]request.QueryFilter{ + {Name: "other", Value: "foo", Filter: request.QueryFilterEQ}, + }, "name") + assert.Nil(t, f) + }) + + t.Run("only processes the matching field", func(t *testing.T) { + f := request.FilterStringFromAip([]request.QueryFilter{ + {Name: "name", Value: "foo", Filter: request.QueryFilterEQ}, + {Name: "other", Value: "bar", Filter: request.QueryFilterNEQ}, + }, "name") + require.NotNil(t, f) + require.NotNil(t, f.Eq) + assert.Equal(t, "foo", *f.Eq) + assert.Nil(t, f.Neq) + }) + + filterOpCases := []struct { + name string + filterOp request.QueryFilterOp + value string + checkFunc func(t *testing.T, f *filters.StringFilter) + }{ + { + name: "eq", filterOp: request.QueryFilterEQ, value: "v", + checkFunc: func(t *testing.T, f *filters.StringFilter) { + t.Helper() + require.NotNil(t, f.Eq) + assert.Equal(t, "v", *f.Eq) + }, + }, + { + name: "neq", filterOp: request.QueryFilterNEQ, value: "v", + checkFunc: func(t *testing.T, f *filters.StringFilter) { + t.Helper() + require.NotNil(t, f.Neq) + assert.Equal(t, "v", *f.Neq) + }, + }, + { + name: "gt", filterOp: request.QueryFilterGT, value: "v", + checkFunc: func(t *testing.T, f *filters.StringFilter) { + t.Helper() + require.NotNil(t, f.Gt) + assert.Equal(t, "v", *f.Gt) + }, + }, + { + name: "gte", filterOp: request.QueryFilterGTE, value: "v", + checkFunc: func(t *testing.T, f *filters.StringFilter) { + t.Helper() + require.NotNil(t, f.Gte) + assert.Equal(t, "v", *f.Gte) + }, + }, + { + name: "lt", filterOp: request.QueryFilterLT, value: "v", + checkFunc: func(t *testing.T, f *filters.StringFilter) { + t.Helper() + require.NotNil(t, f.Lt) + assert.Equal(t, "v", *f.Lt) + }, + }, + { + name: "lte", filterOp: request.QueryFilterLTE, value: "v", + checkFunc: func(t *testing.T, f *filters.StringFilter) { + t.Helper() + require.NotNil(t, f.Lte) + assert.Equal(t, "v", *f.Lte) + }, + }, + { + name: "contains", filterOp: request.QueryFilterContains, value: "v", + checkFunc: func(t *testing.T, f *filters.StringFilter) { + t.Helper() + require.NotNil(t, f.Contains) + assert.Equal(t, "v", *f.Contains) + }, + }, + { + name: "oeq", filterOp: request.QueryFilterOrEQ, value: "v", + checkFunc: func(t *testing.T, f *filters.StringFilter) { + t.Helper() + require.NotNil(t, f.Oeq) + assert.Equal(t, "v", *f.Oeq) + }, + }, + { + name: "ocontains", filterOp: request.QueryFilterOrContains, value: "v", + checkFunc: func(t *testing.T, f *filters.StringFilter) { + t.Helper() + require.NotNil(t, f.Ocontains) + assert.Equal(t, "v", *f.Ocontains) + }, + }, + { + name: "exists", filterOp: request.QueryFilterExists, + checkFunc: func(t *testing.T, f *filters.StringFilter) { + t.Helper() + require.NotNil(t, f.Exists) + assert.True(t, *f.Exists) + }, + }, + } + + for _, tc := range filterOpCases { + t.Run("maps "+tc.name+" to StringFilter", func(t *testing.T) { + f := request.FilterStringFromAip([]request.QueryFilter{ + {Name: "field", Value: tc.value, Filter: tc.filterOp}, + }, "field") + require.NotNil(t, f) + tc.checkFunc(t, f) + }) + } +} diff --git a/api/v3/request/cursor.go b/api/v3/request/cursor.go new file mode 100644 index 0000000000..f81506c2fb --- /dev/null +++ b/api/v3/request/cursor.go @@ -0,0 +1,145 @@ +package request + +import ( + "encoding/base64" + "errors" + "net/url" + "strings" + + "github.com/google/uuid" + "github.com/samber/lo" +) + +// ErrInvalidCursor is used when a cursor does not conform to the expected format. +var ErrInvalidCursor = errors.New("invalid pagination cursor provided") + +const ( + DefaultCipherKey = "Oh hai there! I was originally written in Koko, the super awesome control plane for Kong Gateway! AND VOILA" + cursorVersion = "1" +) + +// Cursor is a representation of an object's ID, as a cursor should +// be opaque and that its format should not be relied upon. +// +// A cursor is used to implement keyset pagination within a database. +// +// Under the hood, this uses a simple XOR cipher. +type Cursor struct{ encoded, decoded, version string } + +// String implements fmt.Stringer & returns the encoded cursor representation of the ID. +func (c *Cursor) String() string { return c.encoded } + +// ID decodes the provided cursor (during instantiation) and returns its representation +// (usually set to a UUID or [UUID:]UUID). If there is no cursor, an empty string is returned. +func (c *Cursor) ID() string { + if c == nil { + return "" + } + return c.decoded +} + +// xorText is a simple XOR cipher implementation. +// Read more: https://en.wikipedia.org/wiki/XOR_cipher +func xorText(cipherKey, input string) string { + var output string + keyLen := len(cipherKey) + for i := range input { + output += string(input[i] ^ cipherKey[i%keyLen]) + } + return output +} + +// EncodeCursor instantiates a new Cursor from an object's ID. A cursor ID can +// be one or more UUIDs separated by the colon char. +// +// You must only provide an ASCII string. If UTF-8 is used (e.g.: graphics), decoding will +// not function properly. There are no error checks for this due to performance reasons. +// +// ErrInvalidCursor be returned when the provided ID is empty. +func EncodeCursor(cipherKey, id string) (*Cursor, error) { + if id == "" { + return nil, ErrInvalidCursor + } + + c := Cursor{ + encoded: xorText(cipherKey, id), + decoded: id, + version: cursorVersion, + } + + // Inject the version into the XOR'ed string. + versionIdx := getVersionIdx(c.encoded) + c.encoded = c.encoded[:versionIdx] + c.version + c.encoded[versionIdx:] + + // Base64 encoding for ASCII compatibility. + c.encoded = base64.StdEncoding.EncodeToString([]byte(c.encoded)) + + // we need to pass the base64 encoded string through QueryEscape as the encoded string will contain + // certain characters which are not valid in a URL parser. + // Example : when the id input is `01960a8d-eccd-72c0-935e-a87370362c2a` + // the base64 encoded string is `f1kZXlEIGBBFABEGRQ1+EhRRMV4ZXEcMSghWVl9bSRNBQApGFQ==` + // The + char in the above string is not valid in a URL parser. + c.encoded = url.QueryEscape(c.encoded) + + return &c, nil +} + +// DecodeCursor instantiates a new Cursor from an encoded cursor value with query escaped value. +// +// Returns ErrInvalidCursor when an invalid cursor is provided (and optionally +// validates the decoded value as a UUID when validateAsUUID is true). +func DecodeCursor(cipherKey, cursor string, validateAsUUID bool) (*Cursor, error) { + if cursor == "" { + return nil, ErrInvalidCursor + } + + unescapeCursor, err := url.QueryUnescape(cursor) + if err != nil { + return nil, ErrInvalidCursor + } + + return decodeCursorAfterQueryUnescape(cipherKey, unescapeCursor, validateAsUUID) +} + +// decodeCursorAfterQueryUnescape instantiates a new Cursor from already unescaped cursor value. +// This function is needed because GetAipAttributes function already +// unescapes the query param so we need not unescape it again. +// +// Returns ErrInvalidCursor when an invalid cursor is provided (and optionally +// validates the decoded value as a UUID when validateAsUUID is true). +func decodeCursorAfterQueryUnescape(cipherKey, cursor string, validateAsUUID bool) (*Cursor, error) { + if cursor == "" { + return nil, ErrInvalidCursor + } + + // All cursors should always be base64 encoded (see store.EncodeCursor). + v, err := base64.StdEncoding.DecodeString(cursor) + if err != nil { + return nil, ErrInvalidCursor + } + + c := Cursor{encoded: cursor, decoded: string(v)} + + // We're adding a single-character version ID to the cursor, in case we ever change this + // implementation. As such, we need to account for it & remove it before XOR'ing the input. + versionIdx := getVersionIdx(c.decoded[1:]) + if c.version = string(c.decoded[versionIdx]); c.version != cursorVersion { + return nil, ErrInvalidCursor + } + c.decoded = xorText(cipherKey, c.decoded[:versionIdx]+c.decoded[versionIdx+1:]) + + if validateAsUUID { + ids := strings.Split(c.decoded, ":") + if !lo.EveryBy(ids, func(id string) bool { _, err := uuid.Parse(id); return err == nil }) { + return nil, ErrInvalidCursor + } + } + + return &c, err +} + +// getVersionIdx returns the index where the single character is, representing the version of this implementation. +func getVersionIdx(input string) int { + // The version is injected halfway in the string to not introduce too much predictability. + return int(float64(len(input) / 2)) //nolint:gomnd +} diff --git a/api/v3/request/cursor_test.go b/api/v3/request/cursor_test.go new file mode 100644 index 0000000000..390317a079 --- /dev/null +++ b/api/v3/request/cursor_test.go @@ -0,0 +1,139 @@ +package request_test + +import ( + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/api/v3/request" +) + +func TestEncodeCursor(t *testing.T) { + t.Run("empty id returns ErrInvalidCursor", func(t *testing.T) { + _, err := request.EncodeCursor(request.DefaultCipherKey, "") + require.Error(t, err) + assert.True(t, errors.Is(err, request.ErrInvalidCursor)) + }) + + t.Run("non-empty id returns non-empty encoded string", func(t *testing.T) { + c, err := request.EncodeCursor(request.DefaultCipherKey, uuid.New().String()) + require.NoError(t, err) + assert.NotEmpty(t, c.String()) + }) + + t.Run("encoded value differs from original id", func(t *testing.T) { + id := uuid.New().String() + c, err := request.EncodeCursor(request.DefaultCipherKey, id) + require.NoError(t, err) + assert.NotEqual(t, id, c.String()) + }) +} + +func TestDecodeCursor(t *testing.T) { + t.Run("empty cursor returns ErrInvalidCursor", func(t *testing.T) { + _, err := request.DecodeCursor(request.DefaultCipherKey, "", false) + require.Error(t, err) + assert.True(t, errors.Is(err, request.ErrInvalidCursor)) + }) + + t.Run("non-base64 input returns ErrInvalidCursor", func(t *testing.T) { + _, err := request.DecodeCursor(request.DefaultCipherKey, "not-valid-base64!!!", false) + require.Error(t, err) + assert.True(t, errors.Is(err, request.ErrInvalidCursor)) + }) + + t.Run("valid base64 with wrong content returns ErrInvalidCursor", func(t *testing.T) { + // base64("hello") — valid base64 but not a valid cursor + _, err := request.DecodeCursor(request.DefaultCipherKey, "aGVsbG8=", false) + require.Error(t, err) + assert.True(t, errors.Is(err, request.ErrInvalidCursor)) + }) +} + +func TestCursorRoundtrip(t *testing.T) { + t.Run("single uuid roundtrip", func(t *testing.T) { + id := uuid.New().String() + encoded, err := request.EncodeCursor(request.DefaultCipherKey, id) + require.NoError(t, err) + + decoded, err := request.DecodeCursor(request.DefaultCipherKey, encoded.String(), false) + require.NoError(t, err) + assert.Equal(t, id, decoded.ID()) + }) + + t.Run("compound uuid roundtrip", func(t *testing.T) { + id := uuid.New().String() + ":" + uuid.New().String() + encoded, err := request.EncodeCursor(request.DefaultCipherKey, id) + require.NoError(t, err) + + decoded, err := request.DecodeCursor(request.DefaultCipherKey, encoded.String(), false) + require.NoError(t, err) + assert.Equal(t, id, decoded.ID()) + }) + + t.Run("custom cipher key roundtrip", func(t *testing.T) { + const key = "custom-cipher-key-long-enough-for-testing-purposes" + id := uuid.New().String() + encoded, err := request.EncodeCursor(key, id) + require.NoError(t, err) + + decoded, err := request.DecodeCursor(key, encoded.String(), false) + require.NoError(t, err) + assert.Equal(t, id, decoded.ID()) + }) + + t.Run("wrong cipher key fails to decode correctly but does not error without uuid validation", func(t *testing.T) { + id := uuid.New().String() + encoded, err := request.EncodeCursor(request.DefaultCipherKey, id) + require.NoError(t, err) + + decoded, err := request.DecodeCursor("wrong-key-that-is-long-enough-for-xor-cipher-test", encoded.String(), false) + // decodes without error but ID will differ + require.NoError(t, err) + assert.NotEqual(t, id, decoded.ID()) + }) +} + +func TestCursorUUIDValidation(t *testing.T) { + t.Run("valid uuid passes validation", func(t *testing.T) { + id := uuid.New().String() + encoded, err := request.EncodeCursor(request.DefaultCipherKey, id) + require.NoError(t, err) + + decoded, err := request.DecodeCursor(request.DefaultCipherKey, encoded.String(), true) + require.NoError(t, err) + assert.Equal(t, id, decoded.ID()) + }) + + t.Run("non-uuid value fails validation", func(t *testing.T) { + encoded, err := request.EncodeCursor(request.DefaultCipherKey, "not-a-uuid") + require.NoError(t, err) + + _, err = request.DecodeCursor(request.DefaultCipherKey, encoded.String(), true) + require.Error(t, err) + assert.True(t, errors.Is(err, request.ErrInvalidCursor)) + }) + + t.Run("compound valid uuids pass validation", func(t *testing.T) { + id := uuid.New().String() + ":" + uuid.New().String() + encoded, err := request.EncodeCursor(request.DefaultCipherKey, id) + require.NoError(t, err) + + decoded, err := request.DecodeCursor(request.DefaultCipherKey, encoded.String(), true) + require.NoError(t, err) + assert.Equal(t, id, decoded.ID()) + }) + + t.Run("compound with one invalid part fails validation", func(t *testing.T) { + id := uuid.New().String() + ":not-a-uuid" + encoded, err := request.EncodeCursor(request.DefaultCipherKey, id) + require.NoError(t, err) + + _, err = request.DecodeCursor(request.DefaultCipherKey, encoded.String(), true) + require.Error(t, err) + assert.True(t, errors.Is(err, request.ErrInvalidCursor)) + }) +} diff --git a/api/v3/server/server.go b/api/v3/server/server.go index 562a5b4c27..0c876b71ef 100644 --- a/api/v3/server/server.go +++ b/api/v3/server/server.go @@ -8,7 +8,6 @@ import ( "net/http" "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/openapi3filter" "github.com/go-chi/chi/v5" "github.com/samber/lo" @@ -237,28 +236,21 @@ func NewServer(config *Config) (*Server, error) { } func (s *Server) RegisterRoutes(r chi.Router) error { - validationRouter, err := oasmiddleware.NewValidationRouter( - context.Background(), - s.swagger, - &oasmiddleware.ValidationRouterOpts{ - DeleteServers: true, - ServerPrefix: s.BaseURL, - }, - ) + specBytes, err := api.GetSpecBytes() if err != nil { - return fmt.Errorf("create validation router: %w", err) + return fmt.Errorf("get spec bytes: %w", err) } - validationMiddleware := oasmiddleware.ValidateRequest(validationRouter, oasmiddleware.ValidateRequestOption{ + validationValidator, err := oasmiddleware.NewValidator(specBytes, s.BaseURL) + if err != nil { + return fmt.Errorf("create validation validator: %w", err) + } + + validationMiddleware := oasmiddleware.ValidateRequest(validationValidator, oasmiddleware.ValidateRequestOption{ RouteNotFoundHook: oasmiddleware.OasRouteNotFoundErrorHook, RouteValidationErrorHook: func(err error, w http.ResponseWriter, r *http.Request) bool { return oasmiddleware.OasValidationErrorHook(r.Context(), err, w, r) }, - FilterOptions: &openapi3filter.Options{ - // No-op auth: auth is handled by other middleware. - AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, - MultiError: true, - }, }) r.Route(s.BaseURL, func(r chi.Router) { diff --git a/api/v3/spec.go b/api/v3/spec.go new file mode 100644 index 0000000000..fbd3d58ad0 --- /dev/null +++ b/api/v3/spec.go @@ -0,0 +1,7 @@ +package v3 + +// GetSpecBytes returns the raw embedded OpenAPI specification bytes. +// Used by libopenapi for request/response validation. +func GetSpecBytes() ([]byte, error) { + return rawSpec() +} diff --git a/go.mod b/go.mod index ab15a854ab..546127a9b3 100644 --- a/go.mod +++ b/go.mod @@ -39,13 +39,15 @@ require ( github.com/invopop/gobl v0.306.0 github.com/jackc/pgx/v5 v5.8.0 github.com/lmittmann/tint v1.1.2 - github.com/mitchellh/mapstructure v1.5.0 + github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c github.com/oapi-codegen/nethttp-middleware v1.1.2 github.com/oapi-codegen/nullable v1.1.0 github.com/oapi-codegen/runtime v1.2.0 github.com/oklog/run v1.1.1-0.20240127200640-eee6e044b77c github.com/oklog/ulid/v2 v2.1.1 github.com/oliveagle/jsonpath v0.1.0 + github.com/pb33f/libopenapi v0.34.2 + github.com/pb33f/libopenapi-validator v0.13.1 github.com/peterbourgon/ctxdata/v4 v4.0.0 github.com/peterldowns/pgtestdb v0.1.1 github.com/prometheus/client_golang v1.23.2 @@ -102,7 +104,7 @@ require ( dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/storage/azdatalake v1.4.1 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect @@ -112,6 +114,7 @@ require ( github.com/authzed/authzed-go v1.4.1 // indirect github.com/authzed/grpcutil v0.0.0-20250221190651-1985b19b35b8 // indirect github.com/awalterschulze/goderive v0.5.1 // indirect + github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad // indirect github.com/bhmj/xpression v0.9.4 // indirect github.com/bitfield/gotestdox v0.2.2 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect @@ -137,11 +140,15 @@ require ( github.com/go-git/go-git/v5 v5.16.2 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-pg/pg/v10 v10.11.1 // indirect github.com/gofrs/uuid/v5 v5.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/go-sql-spanner v1.16.0 // indirect github.com/hamba/avro/v2 v2.29.0 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/huandu/go-clone v1.7.3 // indirect github.com/invopop/validation v0.8.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect @@ -159,16 +166,21 @@ require ( github.com/oapi-codegen/oapi-codegen/v2 v2.6.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/pb33f/jsonpath v0.8.1 // indirect + github.com/pb33f/ordered-map/v2 v2.3.0 // indirect github.com/pgvector/pgvector-go v0.3.0 // indirect github.com/pinecone-io/go-pinecone v1.1.1 // indirect github.com/pjbgf/sha1cd v0.4.0 // indirect github.com/pkoukk/tiktoken-go v0.1.7 // indirect - github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/qdrant/go-client v1.14.1 // indirect github.com/questdb/go-questdb-client/v3 v3.2.0 // indirect github.com/redpanda-data/connect/v4 v4.61.0 // indirect github.com/samber/slog-common v0.19.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect + github.com/shirou/gopsutil/v4 v4.25.8-0.20250809033336-ffcdc2b7662f // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect @@ -180,6 +192,7 @@ require ( github.com/tmc/langchaingo v0.1.13 // indirect github.com/twmb/franz-go/pkg/kadm v1.16.0 // indirect github.com/twmb/franz-go/pkg/sr v1.4.0 // indirect + github.com/uptrace/bun v1.1.17 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect @@ -191,13 +204,18 @@ require ( go.mongodb.org/mongo-driver/v2 v2.2.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 // indirect + go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gorm.io/driver/postgres v1.5.5 // indirect + gorm.io/gorm v1.25.12 // indirect gotest.tools/gotestsum v1.13.0 // indirect k8s.io/apiextensions-apiserver v0.35.0 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect @@ -341,7 +359,7 @@ require ( github.com/go-logr/logr v1.4.3 github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/inflect v0.21.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect @@ -510,14 +528,14 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect golang.org/x/crypto v0.47.0 // indirect - golang.org/x/mod v0.31.0 // indirect + golang.org/x/mod v0.32.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 + golang.org/x/text v0.34.0 golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/tools v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/api v0.247.0 // indirect google.golang.org/genproto v0.0.0-20250707201910-8d1bb00bc6a7 // indirect @@ -526,7 +544,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 k8s.io/klog/v2 v2.130.1 modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index f50d201d07..3f1df2dd9c 100644 --- a/go.sum +++ b/go.sum @@ -741,8 +741,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDm github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/ClickHouse/ch-go v0.70.0 h1:/0lJpiSXxg/7IaJi7TOkKAOHrx0z0OiSMU475EJNAwM= github.com/ClickHouse/ch-go v0.70.0/go.mod h1:gk6B9UqB7UtvTNVruztrh6k85SlrIZiCCSfQFIxKU3s= @@ -977,6 +977,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad h1:3swAvbzgfaI6nKuDDU7BiKfZRdF+h2ZwKgMHd8Ha4t8= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad/go.mod h1:9+nBLYNWkvPcq9ep0owWUsPTLgL9ZXTsZWcCSVGGLJ0= github.com/beanstalkd/go-beanstalk v0.2.0 h1:6UOJugnu47uNB2jJO/lxyDgeD1Yds7owYi1USELqexA= github.com/beanstalkd/go-beanstalk v0.2.0/go.mod h1:/G8YTyChOtpOArwLTQPY1CHB+i212+av35bkPXXj56Y= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -992,6 +994,8 @@ github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CD github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= +github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= +github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -1254,6 +1258,7 @@ github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8 github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -1344,17 +1349,21 @@ github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW github.com/go-openapi/inflect v0.21.0 h1:FoBjBTQEcbg2cJUWX6uwL9OyIW8eqc9k4KhN4lfbeYk= github.com/go-openapi/inflect v0.21.0/go.mod h1:INezMuUu7SJQc2AyR3WO0DqqYUJSj8Kb4hBd7WtjlAw= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= -github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= -github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA= +github.com/go-pg/pg/v10 v10.11.1 h1:vYwbFpqoMpTDphnzIPshPPepdy3VpzD8qo29OFKp4vo= +github.com/go-pg/pg/v10 v10.11.1/go.mod h1:ExJWndhDNNftBdw1Ow83xqpSf4WMSJK8urmXD5VXS1I= github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -1394,8 +1403,8 @@ github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFx github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -1616,8 +1625,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= -github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= @@ -1634,8 +1643,8 @@ github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bP github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= -github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/arc/v2 v2.0.7 h1:QxkVTxwColcduO+LP7eJO56r2hFiG8zEbfAAzRv52KQ= github.com/hashicorp/golang-lru/arc/v2 v2.0.7/go.mod h1:Pe7gBlGdc8clY5LJ0LpJXMt5AmgmWNH1g+oFFVUHOEc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -1869,12 +1878,15 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= @@ -1907,8 +1919,8 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/buildkit v0.14.1 h1:2epLCZTkn4CikdImtsLtIa++7DzCimrrZCT1sway+oI= github.com/moby/buildkit v0.14.1/go.mod h1:1XssG7cAqv5Bz1xcGMxJL123iCv5TYN4Z/qf647gfuk= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -2010,6 +2022,7 @@ github.com/oliveagle/jsonpath v0.1.0/go.mod h1:eqOVx5Vwu4gd2mmMZvVZsgIqNSaW3xxRT github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= @@ -2019,6 +2032,7 @@ github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zw github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= @@ -2045,6 +2059,14 @@ github.com/parquet-go/parquet-go v0.25.1/go.mod h1:AXBuotO1XiBtcqJb/FKFyjBG4aqa3 github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pb33f/jsonpath v0.8.1 h1:84C6QRyx6HcSm6PZnsMpcqYot3IsZ+m0n95+0NbBbvs= +github.com/pb33f/jsonpath v0.8.1/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo= +github.com/pb33f/libopenapi v0.34.2 h1:ValgPCDIVSC1IzPY7rY6GPOslCzaAWEml40IuFGZXOc= +github.com/pb33f/libopenapi v0.34.2/go.mod h1:YOP20KzYe3mhE5301aQzJtzQ9MnvhABBGO7RMttA4V4= +github.com/pb33f/libopenapi-validator v0.13.1 h1:KJimsXewLMIcM0O/wmfBswYJqZwQCkp37IVGfACp868= +github.com/pb33f/libopenapi-validator v0.13.1/go.mod h1:YZQRDh+8xap/H0GM0cJsBrqqT+XLlMivA/qwqRLiidQ= +github.com/pb33f/ordered-map/v2 v2.3.0 h1:k2OhVEQkhTCQMhAicQ3Z6iInzoZNQ7L9MVomwKBZ5WQ= +github.com/pb33f/ordered-map/v2 v2.3.0/go.mod h1:oe5ue+6ZNhy7QN9cPZvPA23Hx0vMHnNVeMg4fGdCANw= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pborman/getopt v0.0.0-20180729010549-6fdd0a2c7117/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pebbe/zmq4 v1.4.0 h1:gO5P92Ayl8GXpPZdYcD62Cwbq0slSBVVQRIXwGSJ6eQ= @@ -2093,8 +2115,8 @@ github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw= github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 h1:S1hI5JiKP7883xBzZAr1ydcxrKNSVNm7+3+JwjxZEsg= +github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25/go.mod h1:ZQntvDG8TkPgljxtA0R9frDoND4QORU1VXz015N5Ks4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -2184,9 +2206,11 @@ github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89 github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M= github.com/samber/slog-multi v1.7.0 h1:GKhbkxU3ujkyMsefkuz4qvE6EcgtSuqjFisPnfdzVLI= github.com/samber/slog-multi v1.7.0/go.mod h1:qTqzmKdPpT0h4PFsTN5rYRgLwom1v+fNGuIrl1Xnnts= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/secure-systems-lab/go-securesystemslib v0.4.0 h1:b23VGrQhTA8cN2CbBw7/FulN9fTtqYUdS5+Oxzt+DUE= -github.com/secure-systems-lab/go-securesystemslib v0.4.0/go.mod h1:FGBZgq2tXWICsxWQW1msNf49F0Pf2Op5Htayx335Qbs= +github.com/secure-systems-lab/go-securesystemslib v0.9.0 h1:rf1HIbL64nUpEIZnjLZ3mcNEL9NBPB0iuVjyxvq3LZc= +github.com/secure-systems-lab/go-securesystemslib v0.9.0/go.mod h1:DVHKMcZ+V4/woA/peqr+L0joiRXbPpQ042GgJckkFgw= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= @@ -2198,9 +2222,8 @@ github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b h1:h+3JX2VoWTFuyQ github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b/go.mod h1:/yeG0My1xr/u+HZrFQ1tOQQQQrOawfyMUH13ai5brBc= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= -github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= -github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= -github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/shirou/gopsutil/v4 v4.25.8-0.20250809033336-ffcdc2b7662f h1:S+PHRM3lk96X0/cGEGUukqltzkX/ekUx0F9DoCGK1G0= +github.com/shirou/gopsutil/v4 v4.25.8-0.20250809033336-ffcdc2b7662f/go.mod h1:4f4j4w8HLMPWEFs3BO2UBBLigKAaWYwkSkbIt/6Q4Ss= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -2266,6 +2289,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -2340,8 +2364,8 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= -github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0= +github.com/uptrace/bun v1.1.17 h1:qxBaEIo0hC/8O3O6GrMDKxqyT+mw5/s0Pn/n6xjyGIk= +github.com/uptrace/bun v1.1.17/go.mod h1:hATAzivtTIRsSJR4B8AXR+uABqnQxr3myKDKEf5iQ9U= github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc= github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= @@ -2350,6 +2374,7 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= +github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= @@ -2470,12 +2495,10 @@ go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 h1:W+m0g+/6v3pa5PgVf2xoFMi5YtNR06WtS7ve5pcvLtM= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0/go.mod h1:JM31r0GGZ/GU94mX8hN4D8v6e40aFlUECSQ48HaLgHM= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.42.0 h1:ZtfnDL+tUrs1F0Pzfwbg2d59Gru9NCH3bgSHBM6LDwU= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.42.0/go.mod h1:hG4Fj/y8TR/tlEDREo8tWstl9fO9gcFkn4xrx0Io8xU= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.42.0 h1:wNMDy/LVGLj2h3p6zg4d0gypKfWKSWI14E1C4smOgl8= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.42.0/go.mod h1:YfbDdXAAkemWJK3H/DshvlrxqFB2rtW4rY6ky/3x/H0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0/go.mod h1:ZQM5lAJpOsKnYagGg/zV2krVqTtaVdYdDkhMoX6Oalg= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= @@ -2519,8 +2542,8 @@ go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= -go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= @@ -2540,9 +2563,12 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= +go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= gocloud.dev v0.26.0/go.mod h1:mkUgejbnbLotorqDyvedJO20XcZNTynmSeVSQS9btVg= golang.org/x/crypto v0.0.0-20180723164146-c126467f60eb/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -2563,10 +2589,12 @@ golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= @@ -2636,8 +2664,8 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -2669,6 +2697,7 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -2701,6 +2730,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= @@ -2709,11 +2739,12 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -2804,6 +2835,7 @@ golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -2837,6 +2869,7 @@ golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -2872,6 +2905,7 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -2879,11 +2913,12 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= -golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= @@ -2891,6 +2926,7 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= @@ -2913,12 +2949,13 @@ golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -3005,8 +3042,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -3333,6 +3370,7 @@ gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UD gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -3368,10 +3406,10 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= -gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= -gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= -gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/driver/postgres v1.5.5 h1:r1VBTQQrOAlUux3JI9V7rdxVWBPPnzxa315qNJUzmjI= +gorm.io/driver/postgres v1.5.5/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo= gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=