Skip to content

Commit

Permalink
feat: implement create, list, get goal REST api no command (#1445)
Browse files Browse the repository at this point in the history
part of #1417
  • Loading branch information
hvn2k1 authored Jan 10, 2025
1 parent d7b8208 commit 8b24d8e
Show file tree
Hide file tree
Showing 11 changed files with 2,005 additions and 464 deletions.
452 changes: 452 additions & 0 deletions api-description/web-api.swagger.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion manifests/bucketeer/charts/web/values.yaml

Large diffs are not rendered by default.

153 changes: 144 additions & 9 deletions pkg/experiment/api/goal.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ package api

import (
"context"
"errors"
"regexp"
"strconv"

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

domainevent "github.com/bucketeer-io/bucketeer/pkg/domainevent/domain"
"github.com/bucketeer-io/bucketeer/pkg/experiment/command"
"github.com/bucketeer-io/bucketeer/pkg/experiment/domain"
v2es "github.com/bucketeer-io/bucketeer/pkg/experiment/storage/v2"
Expand Down Expand Up @@ -55,7 +57,7 @@ func (s *experimentService) GetGoal(ctx context.Context, req *proto.GetGoalReque
}
goal, err := s.getGoalMySQL(ctx, req.Id, req.EnvironmentId)
if err != nil {
if err == v2es.ErrGoalNotFound {
if errors.Is(err, v2es.ErrGoalNotFound) {
dt, err := statusNotFound.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalize(locale.NotFoundError),
Expand Down Expand Up @@ -221,6 +223,9 @@ func (s *experimentService) CreateGoal(
if err != nil {
return nil, err
}
if req.Command == nil {
return s.createGoalNoCommand(ctx, req, editor, localizer)
}
if err := validateCreateGoalRequest(req, localizer); err != nil {
return nil, err
}
Expand Down Expand Up @@ -271,7 +276,7 @@ func (s *experimentService) CreateGoal(
return goalStorage.CreateGoal(ctx, goal, req.EnvironmentId)
})
if err != nil {
if err == v2es.ErrGoalAlreadyExists {
if errors.Is(err, v2es.ErrGoalAlreadyExists) {
dt, err := statusAlreadyExists.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalize(locale.AlreadyExistsError),
Expand All @@ -297,20 +302,116 @@ func (s *experimentService) CreateGoal(
}
return nil, dt.Err()
}
return &proto.CreateGoalResponse{}, nil
return &proto.CreateGoalResponse{
Goal: goal.Goal,
}, nil
}

func validateCreateGoalRequest(req *proto.CreateGoalRequest, localizer locale.Localizer) error {
if req.Command == nil {
dt, err := statusNoCommand.WithDetails(&errdetails.LocalizedMessage{
func (s *experimentService) createGoalNoCommand(
ctx context.Context,
req *proto.CreateGoalRequest,
editor *eventproto.Editor,
localizer locale.Localizer,
) (*proto.CreateGoalResponse, error) {
if err := validateCreateGoalNoCommandRequest(req, localizer); err != nil {
return nil, err
}
goal, err := domain.NewGoal(req.Id, req.Name, req.Description)
if err != nil {
s.logger.Error(
"Failed to create a new goal",
log.FieldsFromImcomingContext(ctx).AddFields(
zap.Error(err),
zap.String("environmentId", req.EnvironmentId),
)...,
)
dt, err := statusInternal.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalizeWithTemplate(locale.RequiredFieldTemplate, "command"),
Message: localizer.MustLocalize(locale.InternalServerError),
})
if err != nil {
return statusInternal.Err()
return nil, statusInternal.Err()
}
return dt.Err()
return nil, dt.Err()
}
tx, err := s.mysqlClient.BeginTx(ctx)
if err != nil {
s.logger.Error(
"Failed to begin transaction",
log.FieldsFromImcomingContext(ctx).AddFields(
zap.Error(err),
)...,
)
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()
}
err = s.mysqlClient.RunInTransaction(ctx, tx, func() error {
goalStorage := v2es.NewGoalStorage(tx)
prev := &domain.Goal{}
e, err := domainevent.NewEvent(
editor,
eventproto.Event_GOAL,
goal.Id,
eventproto.Event_GOAL_CREATED,
&eventproto.GoalCreatedEvent{
Id: goal.Id,
Name: goal.Name,
Description: goal.Description,
Deleted: goal.Deleted,
CreatedAt: goal.CreatedAt,
UpdatedAt: goal.UpdatedAt,
},
req.EnvironmentId,
goal.Goal,
prev,
)
if err != nil {
return err
}
if err := s.publisher.Publish(ctx, e); err != nil {
return err
}
return goalStorage.CreateGoal(ctx, goal, req.EnvironmentId)
})
if err != nil {
if errors.Is(err, v2es.ErrGoalAlreadyExists) {
dt, err := statusAlreadyExists.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalize(locale.AlreadyExistsError),
})
if err != nil {
return nil, statusInternal.Err()
}
return nil, dt.Err()
}
s.logger.Error(
"Failed to create goal",
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 &proto.CreateGoalResponse{
Goal: goal.Goal,
}, nil
}

func validateCreateGoalRequest(req *proto.CreateGoalRequest, localizer locale.Localizer) error {
if req.Command.Id == "" {
dt, err := statusGoalIDRequired.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Expand Down Expand Up @@ -344,6 +445,40 @@ func validateCreateGoalRequest(req *proto.CreateGoalRequest, localizer locale.Lo
return nil
}

func validateCreateGoalNoCommandRequest(req *proto.CreateGoalRequest, localizer locale.Localizer) error {
if req.Id == "" {
dt, err := statusGoalIDRequired.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalizeWithTemplate(locale.RequiredFieldTemplate, "goal_id"),
})
if err != nil {
return statusInternal.Err()
}
return dt.Err()
}
if !goalIDRegex.MatchString(req.Id) {
dt, err := statusInvalidGoalID.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalizeWithTemplate(locale.InvalidArgumentError, "goal_id"),
})
if err != nil {
return statusInternal.Err()
}
return dt.Err()
}
if req.Name == "" {
dt, err := statusGoalNameRequired.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalizeWithTemplate(locale.RequiredFieldTemplate, "name"),
})
if err != nil {
return statusInternal.Err()
}
return dt.Err()
}
return nil
}

func (s *experimentService) UpdateGoal(
ctx context.Context,
req *proto.UpdateGoalRequest,
Expand Down
103 changes: 95 additions & 8 deletions pkg/experiment/api/goal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,6 @@ func TestCreateGoalMySQL(t *testing.T) {
req *experimentproto.CreateGoalRequest
expectedErr error
}{
{
setup: nil,
req: &experimentproto.CreateGoalRequest{
Command: nil,
EnvironmentId: "ns0",
},
expectedErr: createError(statusNoCommand, localizer.MustLocalizeWithTemplate(locale.RequiredFieldTemplate, "command")),
},
{
setup: nil,
req: &experimentproto.CreateGoalRequest{
Expand Down Expand Up @@ -279,6 +271,101 @@ func TestCreateGoalMySQL(t *testing.T) {
}
}

func TestCreateGoalNoCommandMySQL(t *testing.T) {
t.Parallel()
mockController := gomock.NewController(t)
defer mockController.Finish()

ctx := createContextWithTokenAndMetadata(metadata.MD{
"accept-language": []string{"ja"},
})
localizer := locale.NewLocalizer(ctx)
createError := func(status *gstatus.Status, msg string) error {
st, err := status.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: msg,
})
require.NoError(t, err)
return st.Err()
}

patterns := []struct {
desc string
setup func(s *experimentService)
req *experimentproto.CreateGoalRequest
expectedErr error
}{
{
desc: "error: missing Id",
setup: nil,
req: &experimentproto.CreateGoalRequest{
Id: "",
Name: "name-0",
EnvironmentId: "ns0",
},
expectedErr: createError(statusGoalIDRequired, localizer.MustLocalizeWithTemplate(locale.RequiredFieldTemplate, "goal_id")),
},
{
desc: "error: invalid Id",
setup: nil,
req: &experimentproto.CreateGoalRequest{
Id: "bucketeer_goal_id?",
Name: "name-0",
EnvironmentId: "ns0",
},
expectedErr: createError(statusInvalidGoalID, localizer.MustLocalizeWithTemplate(locale.InvalidArgumentError, "goal_id")),
},
{
desc: "error: missing Name",
setup: nil,
req: &experimentproto.CreateGoalRequest{
EnvironmentId: "ns0",
Id: "Bucketeer-id-2019",
Name: "",
},
expectedErr: createError(statusGoalNameRequired, localizer.MustLocalizeWithTemplate(locale.RequiredFieldTemplate, "name")),
},
{
desc: "error: ErrGoalAlreadyExists",
setup: func(s *experimentService) {
s.mysqlClient.(*mysqlmock.MockClient).EXPECT().BeginTx(gomock.Any()).Return(nil, nil)
s.mysqlClient.(*mysqlmock.MockClient).EXPECT().RunInTransaction(
gomock.Any(), gomock.Any(), gomock.Any(),
).Return(v2es.ErrGoalAlreadyExists)
},
req: &experimentproto.CreateGoalRequest{
Id: "Bucketeer-id-2019",
Name: "name-0",
EnvironmentId: "ns0",
},
expectedErr: createError(statusAlreadyExists, localizer.MustLocalize(locale.AlreadyExistsError)),
},
{
desc: "success",
setup: func(s *experimentService) {
s.mysqlClient.(*mysqlmock.MockClient).EXPECT().BeginTx(gomock.Any()).Return(nil, nil)
s.mysqlClient.(*mysqlmock.MockClient).EXPECT().RunInTransaction(
gomock.Any(), gomock.Any(), gomock.Any(),
).Return(nil)
},
req: &experimentproto.CreateGoalRequest{
Id: "Bucketeer-id-2020",
Name: "name-1",
EnvironmentId: "ns0",
},
expectedErr: nil,
},
}
for _, p := range patterns {
service := createExperimentService(mockController, nil, nil, nil)
if p.setup != nil {
p.setup(service)
}
_, err := service.CreateGoal(ctx, p.req)
assert.Equal(t, p.expectedErr, err, p.desc)
}
}

func TestUpdateGoalMySQL(t *testing.T) {
t.Parallel()
mockController := gomock.NewController(t)
Expand Down
2 changes: 1 addition & 1 deletion proto/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ openapi-api-gen:
--openapiv2_opt=disable_service_tags=true \
${GIT_TOP_DIR}/proto/gateway/*.proto; \

PROTO_OPENAPI_WEB_TARGETS := ${GIT_TOP_DIR}/proto/openapi/web_default_settings.proto ${GIT_TOP_DIR}/proto/auth/service.proto ${GIT_TOP_DIR}/proto/environment/service.proto ${GIT_TOP_DIR}/proto/account/service.proto ${GIT_TOP_DIR}/proto/notification/service.proto ${GIT_TOP_DIR}/proto/push/service.proto ${GIT_TOP_DIR}/proto/feature/service.proto
PROTO_OPENAPI_WEB_TARGETS := ${GIT_TOP_DIR}/proto/openapi/web_default_settings.proto ${GIT_TOP_DIR}/proto/auth/service.proto ${GIT_TOP_DIR}/proto/environment/service.proto ${GIT_TOP_DIR}/proto/account/service.proto ${GIT_TOP_DIR}/proto/notification/service.proto ${GIT_TOP_DIR}/proto/push/service.proto ${GIT_TOP_DIR}/proto/feature/service.proto ${GIT_TOP_DIR}/proto/experiment/service.proto
.PHONY: openapi-web-gen
openapi-web-gen:
protoc \
Expand Down
Binary file modified proto/experiment/proto_descriptor.pb
Binary file not shown.
Loading

0 comments on commit 8b24d8e

Please sign in to comment.