autocompletequeryservice.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. package asset
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "sort"
  7. "strings"
  8. "github.com/opencost/opencost/core/pkg/filter"
  9. "github.com/opencost/opencost/core/pkg/opencost"
  10. )
  11. // ErrAutocompleteBadRequest indicates a client error in an autocomplete request.
  12. var ErrAutocompleteBadRequest = errors.New("autocomplete bad request")
  13. // IsAutocompleteBadRequest reports whether err is a client validation error.
  14. func IsAutocompleteBadRequest(err error) bool {
  15. return errors.Is(err, ErrAutocompleteBadRequest)
  16. }
  17. const DefaultAutocompleteResultLimit = 100
  18. const MaxAutocompleteResultLimit = 1000
  19. type AssetAutocompleteRequest struct {
  20. TenantID string
  21. Search string
  22. Field string
  23. Limit int
  24. Window opencost.Window
  25. Filter filter.Filter
  26. }
  27. type AssetAutocompleteResponse struct {
  28. Data []string `json:"data"`
  29. }
  30. type AutocompleteQueryService interface {
  31. QueryAssetAutocomplete(AssetAutocompleteRequest, context.Context) (*AssetAutocompleteResponse, error)
  32. }
  33. func QueryAssetAutocompleteFromSet(assetSet *opencost.AssetSet, req AssetAutocompleteRequest) (*AssetAutocompleteResponse, error) {
  34. if err := validateAssetAutocompleteWindow(req.Window); err != nil {
  35. return nil, err
  36. }
  37. if req.TenantID == "" {
  38. return nil, fmt.Errorf("%w: tenant ID is required", ErrAutocompleteBadRequest)
  39. }
  40. field, err := validateAutocompleteField(req.Field)
  41. if err != nil {
  42. return nil, fmt.Errorf("%w: invalid field: %w", ErrAutocompleteBadRequest, err)
  43. }
  44. limit := req.Limit
  45. if limit <= 0 {
  46. limit = DefaultAutocompleteResultLimit
  47. }
  48. if limit > MaxAutocompleteResultLimit {
  49. return nil, fmt.Errorf("%w: exceeded maximum autocomplete result limit of %d", ErrAutocompleteBadRequest, MaxAutocompleteResultLimit)
  50. }
  51. var matcher opencost.AssetMatcher
  52. if req.Filter != nil {
  53. compiler := opencost.NewAssetMatchCompiler()
  54. matcher, err = compiler.Compile(req.Filter)
  55. if err != nil {
  56. return nil, fmt.Errorf("%w: failed to compile filter: %w", ErrAutocompleteBadRequest, err)
  57. }
  58. }
  59. search := strings.ToLower(req.Search)
  60. results := map[string]struct{}{}
  61. for _, a := range assetSet.Assets {
  62. if a == nil {
  63. continue
  64. }
  65. if matcher != nil && !matcher.Matches(a) {
  66. continue
  67. }
  68. values := assetAutocompleteValues(a, field)
  69. for _, value := range values {
  70. if value == "" {
  71. continue
  72. }
  73. if search != "" && !strings.Contains(strings.ToLower(value), search) {
  74. continue
  75. }
  76. results[value] = struct{}{}
  77. }
  78. }
  79. data := make([]string, 0, len(results))
  80. for value := range results {
  81. data = append(data, value)
  82. }
  83. sort.Strings(data)
  84. if len(data) > limit {
  85. data = data[:limit]
  86. }
  87. return &AssetAutocompleteResponse{Data: data}, nil
  88. }
  89. func validateAutocompleteField(field string) (string, error) {
  90. f := strings.ToLower(field)
  91. switch f {
  92. case "account", "cluster", "name", "provider", "providerid", "type", "category":
  93. return f, nil
  94. }
  95. if f == "label" {
  96. return f, nil
  97. }
  98. if strings.HasPrefix(f, "label:") {
  99. _, labelKey, _ := strings.Cut(f, ":")
  100. return "label:" + labelKey, nil
  101. }
  102. return "", fmt.Errorf("unrecognized field: %s", field)
  103. }
  104. func validateAssetAutocompleteWindow(window opencost.Window) error {
  105. if window.IsOpen() {
  106. return fmt.Errorf("%w: invalid window: %s", ErrAutocompleteBadRequest, window.String())
  107. }
  108. if window.Start() == nil || window.End() == nil {
  109. return fmt.Errorf("%w: invalid window: missing start or end", ErrAutocompleteBadRequest)
  110. }
  111. return nil
  112. }
  113. func assetAutocompleteValues(asset opencost.Asset, field string) []string {
  114. props := asset.GetProperties()
  115. if props == nil {
  116. return nil
  117. }
  118. switch {
  119. case field == "account":
  120. return []string{props.Account}
  121. case field == "cluster":
  122. return []string{props.Cluster}
  123. case field == "name":
  124. return []string{props.Name}
  125. case field == "provider":
  126. return []string{props.Provider}
  127. case field == "providerid":
  128. return []string{props.ProviderID}
  129. case field == "type":
  130. return []string{asset.Type().String()}
  131. case field == "category":
  132. return []string{props.Category}
  133. case field == "label":
  134. keys := make([]string, 0, len(asset.GetLabels()))
  135. for key := range asset.GetLabels() {
  136. keys = append(keys, key)
  137. }
  138. return keys
  139. case strings.HasPrefix(field, "label:"):
  140. labelName := strings.TrimPrefix(field, "label:")
  141. if value, ok := mapValueFold(asset.GetLabels(), labelName); ok {
  142. return []string{value}
  143. }
  144. }
  145. return nil
  146. }
  147. func mapValueFold(values map[string]string, key string) (string, bool) {
  148. if v, ok := values[key]; ok {
  149. return v, true
  150. }
  151. for k, v := range values {
  152. if strings.EqualFold(k, key) {
  153. return v, true
  154. }
  155. }
  156. return "", false
  157. }