Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions api-description/web-api.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5111,6 +5111,72 @@ paths:
$ref: '#/definitions/featureCloneFeatureRequest'
tags:
- Feature
/v1/feature/user-attribute-keys:
get:
summary: Get User Attribute Keys
description: Get all user attribute keys for the specified environment. To call this API, you need at least a MEMBER role in the specified organizations.
operationId: web.v1.feature.getUserAttributeKeys
responses:
"200":
description: A successful response.
schema:
$ref: '#/definitions/featureGetUserAttributeKeysResponse'
"400":
description: Returned for bad requests that may have failed validation.
schema:
$ref: '#/definitions/googlerpcStatus'
examples:
application/json:
code: 3
details: []
message: invalid arguments error
"401":
description: Request could not be authenticated (authentication required).
schema:
$ref: '#/definitions/googlerpcStatus'
examples:
application/json:
code: 16
details: []
message: not authenticated
"403":
description: Request was authenticated but forbidden (authorization required).
schema:
$ref: '#/definitions/googlerpcStatus'
examples:
application/json:
code: 7
details: []
message: permission denied
"500":
description: An unexpected error occurred.
schema:
$ref: '#/definitions/googlerpcStatus'
examples:
application/json:
code: 13
details: []
message: internal error
"503":
description: Returned for internal errors.
schema:
$ref: '#/definitions/googlerpcStatus'
examples:
application/json:
code: 13
details: []
message: internal
default:
description: An unexpected error response.
schema:
$ref: '#/definitions/googlerpcStatus'
parameters:
- name: environmentId
in: query
required: true
type: string
tags:
- Feature
/v1/feature_history:
get:
summary: List Feature History
Expand Down Expand Up @@ -12323,6 +12389,13 @@ definitions:
properties:
user:
$ref: '#/definitions/featureSegmentUser'
featureGetUserAttributeKeysResponse:
type: object
properties:
userAttributeKeys:
type: array
items:
type: string
featureListEnabledFeaturesResponse:
type: object
properties:
Expand Down
4 changes: 3 additions & 1 deletion pkg/feature/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type FeatureService struct {
batchClient btclient.Client
environmentClient envclient.Client
segmentUsersCache cachev3.SegmentUsersCache
userAttributesCache cachev3.UserAttributesCache
segmentUsersPublisher publisher.Publisher
domainPublisher publisher.Publisher
flightgroup singleflight.Group
Expand All @@ -86,7 +87,7 @@ func NewFeatureService(
autoOpsClient autoopsclient.Client,
batchClient btclient.Client,
environmentClient envclient.Client,
v3Cache cache.MultiGetCache,
v3Cache cache.MultiGetDeleteCountCache,
segmentUsersPublisher publisher.Publisher,
domainPublisher publisher.Publisher,
triggerURL string,
Expand All @@ -113,6 +114,7 @@ func NewFeatureService(
environmentClient: environmentClient,
featuresCache: cachev3.NewFeaturesCache(v3Cache),
segmentUsersCache: cachev3.NewSegmentUsersCache(v3Cache),
userAttributesCache: cachev3.NewUserAttributesCache(v3Cache),
segmentUsersPublisher: segmentUsersPublisher,
domainPublisher: domainPublisher,
triggerURL: triggerURL,
Expand Down
3 changes: 3 additions & 0 deletions pkg/feature/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ func createFeatureService(c *gomock.Controller) *FeatureService {
bt,
env,
cachev3mock.NewMockSegmentUsersCache(c),
cachev3mock.NewMockUserAttributesCache(c),
p,
p,
singleflight.Group{},
Expand Down Expand Up @@ -162,6 +163,7 @@ func createFeatureServiceNew(c *gomock.Controller) *FeatureService {
environmentClient: envclientmock.NewMockClient(c),
featuresCache: cachev3mock.NewMockFeaturesCache(c),
segmentUsersPublisher: segmentUsersPublisher,
userAttributesCache: cachev3mock.NewMockUserAttributesCache(c),
domainPublisher: domainPublisher,
batchClient: btclientmock.NewMockClient(c),
triggerURL: "http://localhost",
Expand Down Expand Up @@ -199,6 +201,7 @@ func createFeatureServiceWithGetAccountByEnvironmentMock(c *gomock.Controller, r
experimentClient: experimentclientmock.NewMockClient(c),
featuresCache: cachev3mock.NewMockFeaturesCache(c),
segmentUsersPublisher: segmentUsersPublisher,
userAttributesCache: cachev3mock.NewMockUserAttributesCache(c),
domainPublisher: domainPublisher,
triggerURL: "http://localhost",
opts: &defaultOptions,
Expand Down
64 changes: 64 additions & 0 deletions pkg/feature/api/user_attribute.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2025 The Bucketeer Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package api

import (
"context"

"go.uber.org/zap"
"google.golang.org/genproto/googleapis/rpc/errdetails"

"github.com/bucketeer-io/bucketeer/pkg/locale"
"github.com/bucketeer-io/bucketeer/pkg/log"
accountproto "github.com/bucketeer-io/bucketeer/proto/account"
featureproto "github.com/bucketeer-io/bucketeer/proto/feature"
)

func (s *FeatureService) GetUserAttributeKeys(
Comment thread
cre8ivejp marked this conversation as resolved.
ctx context.Context,
req *featureproto.GetUserAttributeKeysRequest,
) (*featureproto.GetUserAttributeKeysResponse, error) {
localizer := locale.NewLocalizer(ctx)
_, err := s.checkEnvironmentRole(
ctx, accountproto.AccountV2_Role_Environment_VIEWER,
req.EnvironmentId, localizer)
if err != nil {
s.logger.Error("Failed to get user attribute keys", zap.Error(err))
return nil, err
}

Comment on lines +34 to +41

Copilot AI Jul 15, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check for empty EnvironmentId happens after permission validation, so missing-ID errors may be masked by permission errors. Consider validating req.EnvironmentId is non-empty before calling checkEnvironmentRole.

Suggested change
_, err := s.checkEnvironmentRole(
ctx, accountproto.AccountV2_Role_Environment_VIEWER,
req.EnvironmentId, localizer)
if err != nil {
s.logger.Error("Failed to get user attribute keys", zap.Error(err))
return nil, err
}

Copilot uses AI. Check for mistakes.
userAttributeKeys, err := s.userAttributesCache.GetUserAttributeKeyAll(req.EnvironmentId)
if err != nil {
s.logger.Error(
"Failed to get user attribute keys",
log.FieldsFromImcomingContext(ctx).AddFields(
zap.Error(err),
zap.String("environmentId", req.EnvironmentId),
)...,
)
dt, err := statusInternal.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalize(locale.InternalServerError),
})
if err != nil {
return nil, statusInternal.Err()
}
return nil, dt.Err()
}

return &featureproto.GetUserAttributeKeysResponse{
UserAttributeKeys: userAttributeKeys,
}, nil
}
160 changes: 160 additions & 0 deletions pkg/feature/api/user_attribute_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright 2025 The Bucketeer Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package api

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"google.golang.org/grpc/metadata"

cachev3mock "github.com/bucketeer-io/bucketeer/pkg/cache/v3/mock"

"github.com/bucketeer-io/bucketeer/pkg/locale"
accountproto "github.com/bucketeer-io/bucketeer/proto/account"
featureproto "github.com/bucketeer-io/bucketeer/proto/feature"
)

func TestGetUserAttributeKeys(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An e2e test for this API would be nice!

t.Parallel()
mockController := gomock.NewController(t)
defer mockController.Finish()

patterns := []struct {
desc string
service *FeatureService
context context.Context
setup func(*FeatureService)
input *featureproto.GetUserAttributeKeysRequest
expected *featureproto.GetUserAttributeKeysResponse
getExpectedErr func(localizer locale.Localizer) error
}{
{
desc: "error: permission denied",
service: createFeatureServiceWithGetAccountByEnvironmentMock(mockController, accountproto.AccountV2_Role_Organization_UNASSIGNED, accountproto.AccountV2_Role_Environment_UNASSIGNED),
context: createContextWithTokenRoleUnassigned(),
setup: func(s *FeatureService) {},
input: &featureproto.GetUserAttributeKeysRequest{
EnvironmentId: "ns0",
},
getExpectedErr: func(localizer locale.Localizer) error {
return createError(t, statusPermissionDenied, localizer.MustLocalize(locale.PermissionDenied), localizer)
},
},
{
desc: "error: cache error",
service: createFeatureServiceNew(mockController),
context: createContextWithToken(),
setup: func(s *FeatureService) {
s.userAttributesCache.(*cachev3mock.MockUserAttributesCache).EXPECT().
GetUserAttributeKeyAll("ns0").
Return(nil, errors.New("cache error"))
},
input: &featureproto.GetUserAttributeKeysRequest{
EnvironmentId: "ns0",
},
getExpectedErr: func(localizer locale.Localizer) error {
return createError(t, statusInternal, localizer.MustLocalize(locale.InternalServerError), localizer)
},
},
{
desc: "success: empty user attribute keys",
service: createFeatureServiceNew(mockController),
context: createContextWithToken(),
setup: func(s *FeatureService) {
s.userAttributesCache.(*cachev3mock.MockUserAttributesCache).EXPECT().
GetUserAttributeKeyAll("ns0").
Return([]string{
"key1",
"key2",
}, nil)
},
input: &featureproto.GetUserAttributeKeysRequest{
EnvironmentId: "ns0",
},
expected: &featureproto.GetUserAttributeKeysResponse{
UserAttributeKeys: []string{
"key1",
"key2",
},
},
getExpectedErr: func(localizer locale.Localizer) error {
return nil
},
},
{
desc: "success: with user attribute keys",
service: createFeatureServiceNew(mockController),
context: createContextWithToken(),
setup: func(s *FeatureService) {
expectedKeys := []string{"appVersion", "platform", "country", "deviceType"}
s.userAttributesCache.(*cachev3mock.MockUserAttributesCache).EXPECT().
GetUserAttributeKeyAll("ns0").
Return(expectedKeys, nil)
},
input: &featureproto.GetUserAttributeKeysRequest{
EnvironmentId: "ns0",
},
expected: &featureproto.GetUserAttributeKeysResponse{
UserAttributeKeys: []string{"appVersion", "platform", "country", "deviceType"},
},
getExpectedErr: func(localizer locale.Localizer) error {
return nil
},
},
{
desc: "success: with Viewer Account",
service: createFeatureServiceWithGetAccountByEnvironmentMock(mockController, accountproto.AccountV2_Role_Organization_MEMBER, accountproto.AccountV2_Role_Environment_VIEWER),
context: createContextWithTokenRoleUnassigned(),
setup: func(s *FeatureService) {
expectedKeys := []string{"appVersion", "platform"}
s.userAttributesCache.(*cachev3mock.MockUserAttributesCache).EXPECT().
GetUserAttributeKeyAll("ns0").
Return(expectedKeys, nil)
},
input: &featureproto.GetUserAttributeKeysRequest{
EnvironmentId: "ns0",
},
expected: &featureproto.GetUserAttributeKeysResponse{
UserAttributeKeys: []string{"appVersion", "platform"},
},
getExpectedErr: func(localizer locale.Localizer) error {
return nil
},
},
}
for _, p := range patterns {
t.Run(p.desc, func(t *testing.T) {
fs := p.service
if p.setup != nil {
p.setup(fs)
}
ctx := p.context
ctx = metadata.NewIncomingContext(ctx, metadata.MD{
"accept-language": []string{"ja"},
})
localizer := locale.NewLocalizer(ctx)

resp, err := fs.GetUserAttributeKeys(ctx, p.input)
assert.Equal(t, p.getExpectedErr(localizer), err)
if err == nil {
assert.Equal(t, p.expected, resp)
}
})
}
}
20 changes: 20 additions & 0 deletions pkg/feature/client/mock/client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading