Skip to content

Commit

Permalink
implement create experiment no command
Browse files Browse the repository at this point in the history
  • Loading branch information
hvn2k1 committed Jan 21, 2025
1 parent ea04761 commit 8b34aa4
Show file tree
Hide file tree
Showing 11 changed files with 1,302 additions and 344 deletions.
76 changes: 76 additions & 0 deletions api-description/web-api.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3101,6 +3101,54 @@ paths:
type: string
tags:
- experiment
post:
summary: Create
description: Create an experiment.
operationId: web.v1.experiment.create
responses:
"200":
description: A successful response.
schema:
$ref: '#/definitions/experimentCreateExperimentResponse'
"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
"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: body
in: body
required: true
schema:
$ref: '#/definitions/experimentCreateExperimentRequest'
tags:
- experiment
/v1/experiments:
get:
summary: List
Expand Down Expand Up @@ -6141,6 +6189,34 @@ definitions:
type: string
baseVariationId:
type: string
experimentCreateExperimentRequest:
type: object
properties:
command:
$ref: '#/definitions/experimentCreateExperimentCommand'
description: deprecated
environmentId:
type: string
featureId:
type: string
startAt:
type: string
format: int64
stopAt:
type: string
format: int64
goalIds:
type: array
items:
type: string
name:
type: string
description:
type: string
baseVariationId:
type: string
required:
- environmentId
experimentCreateExperimentResponse:
type: object
properties:
Expand Down
2 changes: 1 addition & 1 deletion manifests/bucketeer/charts/web/values.yaml

Large diffs are not rendered by default.

33 changes: 17 additions & 16 deletions pkg/experiment/api/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,21 @@ import (
)

var (
statusInternal = gstatus.New(codes.Internal, "experiment: internal")
statusInvalidCursor = gstatus.New(codes.InvalidArgument, "experiment: cursor is invalid")
statusNoCommand = gstatus.New(codes.InvalidArgument, "experiment: must contain at least one command")
statusFeatureIDRequired = gstatus.New(codes.InvalidArgument, "experiment: feature id must be specified")
statusExperimentIDRequired = gstatus.New(codes.InvalidArgument, "experiment: experiment id must be specified")
statusGoalIDRequired = gstatus.New(codes.InvalidArgument, "experiment: goal id must be specified")
statusInvalidGoalID = gstatus.New(codes.InvalidArgument, "experiment: invalid goal id")
statusGoalNameRequired = gstatus.New(codes.InvalidArgument, "experiment: goal name must be specified")
statusPeriodTooLong = gstatus.New(codes.InvalidArgument, "experiment: period too long")
statusInvalidOrderBy = gstatus.New(codes.InvalidArgument, "expriment: order_by is invalid")
statusNotFound = gstatus.New(codes.NotFound, "experiment: not found")
statusGoalNotFound = gstatus.New(codes.NotFound, "experiment: goal not found")
statusFeatureNotFound = gstatus.New(codes.NotFound, "experiment: feature not found")
statusAlreadyExists = gstatus.New(codes.AlreadyExists, "experiment: already exists")
statusUnauthenticated = gstatus.New(codes.Unauthenticated, "experiment: unauthenticated")
statusPermissionDenied = gstatus.New(codes.PermissionDenied, "experiment: permission denied")
statusInternal = gstatus.New(codes.Internal, "experiment: internal")
statusInvalidCursor = gstatus.New(codes.InvalidArgument, "experiment: cursor is invalid")
statusNoCommand = gstatus.New(codes.InvalidArgument, "experiment: must contain at least one command")
statusFeatureIDRequired = gstatus.New(codes.InvalidArgument, "experiment: feature id must be specified")
statusExperimentIDRequired = gstatus.New(codes.InvalidArgument, "experiment: experiment id must be specified")
statusExperimentNameRequired = gstatus.New(codes.InvalidArgument, "experiment: experiment name must be specified")
statusGoalIDRequired = gstatus.New(codes.InvalidArgument, "experiment: goal id must be specified")
statusInvalidGoalID = gstatus.New(codes.InvalidArgument, "experiment: invalid goal id")
statusGoalNameRequired = gstatus.New(codes.InvalidArgument, "experiment: goal name must be specified")
statusPeriodTooLong = gstatus.New(codes.InvalidArgument, "experiment: period too long")
statusInvalidOrderBy = gstatus.New(codes.InvalidArgument, "expriment: order_by is invalid")
statusNotFound = gstatus.New(codes.NotFound, "experiment: not found")
statusGoalNotFound = gstatus.New(codes.NotFound, "experiment: goal not found")
statusFeatureNotFound = gstatus.New(codes.NotFound, "experiment: feature not found")
statusAlreadyExists = gstatus.New(codes.AlreadyExists, "experiment: already exists")
statusUnauthenticated = gstatus.New(codes.Unauthenticated, "experiment: unauthenticated")
statusPermissionDenied = gstatus.New(codes.PermissionDenied, "experiment: permission denied")
)
204 changes: 196 additions & 8 deletions pkg/experiment/api/experiment.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ package api
import (
"context"
"errors"
domainevent "github.com/bucketeer-io/bucketeer/pkg/domainevent/domain"
"github.com/jinzhu/copier"
"strconv"

"go.uber.org/zap"
Expand Down Expand Up @@ -239,6 +241,9 @@ func (s *experimentService) CreateExperiment(
if err != nil {
return nil, err
}
if req.Command == nil {
return s.createExperimentNoCommand(ctx, req, editor, localizer)
}
if err := validateCreateExperimentRequest(req, localizer); err != nil {
return nil, err
}
Expand Down Expand Up @@ -276,7 +281,7 @@ func (s *experimentService) CreateExperiment(
for _, gid := range req.Command.GoalIds {
_, err := s.getGoalMySQL(ctx, gid, req.EnvironmentId)
if err != nil {
if err == v2es.ErrGoalNotFound {
if errors.Is(err, v2es.ErrGoalNotFound) {
dt, err := statusGoalNotFound.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalize(locale.NotFoundError),
Expand Down Expand Up @@ -359,7 +364,7 @@ func (s *experimentService) CreateExperiment(
return experimentStorage.CreateExperiment(ctx, experiment, req.EnvironmentId)
})
if err != nil {
if err == v2es.ErrExperimentAlreadyExists {
if errors.Is(err, v2es.ErrExperimentAlreadyExists) {
dt, err := statusAlreadyExists.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalize(locale.AlreadyExistsError),
Expand Down Expand Up @@ -390,17 +395,148 @@ func (s *experimentService) CreateExperiment(
}, nil
}

func validateCreateExperimentRequest(req *proto.CreateExperimentRequest, localizer locale.Localizer) error {
if req.Command == nil {
dt, err := statusNoCommand.WithDetails(&errdetails.LocalizedMessage{
func (s *experimentService) createExperimentNoCommand(
ctx context.Context,
req *proto.CreateExperimentRequest,
editor *eventproto.Editor,
localizer locale.Localizer,
) (*proto.CreateExperimentResponse, error) {
err := validateCreateExperimentRequestNoCommand(req, localizer)
if err != nil {
return nil, err
}
getFeatureResp, err := s.featureClient.GetFeature(ctx, &featureproto.GetFeatureRequest{
Id: req.FeatureId,
EnvironmentId: req.EnvironmentId,
})
if err != nil {
if code := status.Code(err); code == codes.NotFound {
dt, err := statusFeatureNotFound.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalize(locale.NotFoundError),
})
if err != nil {
return nil, statusInternal.Err()
}
return nil, dt.Err()
}
s.logger.Error(
"Failed to get feature",
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()
}
experiment, err := domain.NewExperiment(
req.FeatureId,
getFeatureResp.Feature.Version,
getFeatureResp.Feature.Variations,
req.GoalIds,
req.StartAt,
req.StopAt,
req.Name,
req.Description,
req.BaseVariationId,
editor.Email,
)
if err != nil {
s.logger.Error(
"Failed to create a new experiment",
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()
}
err = s.mysqlClient.RunInTransactionV2(ctx, func(ctxWithTx context.Context, tx mysql.Transaction) error {
experimentStorage := v2es.NewExperimentStorage(s.mysqlClient)
prev := &domain.Experiment{}
if err = copier.Copy(prev, experiment); err != nil {
return err
}
e, err := domainevent.NewEvent(
editor,
eventproto.Event_EXPERIMENT,
experiment.Id,
eventproto.Event_EXPERIMENT_CREATED,
&eventproto.ExperimentCreatedEvent{
Id: experiment.Id,
FeatureId: experiment.FeatureId,
FeatureVersion: experiment.FeatureVersion,
Variations: experiment.Variations,
GoalIds: experiment.GoalIds,
StartAt: experiment.StartAt,
StopAt: experiment.StopAt,
StoppedAt: experiment.StoppedAt,
CreatedAt: experiment.CreatedAt,
UpdatedAt: experiment.UpdatedAt,
Name: experiment.Name,
Description: experiment.Description,
BaseVariationId: experiment.BaseVariationId,
},
req.EnvironmentId,
experiment.Experiment,
prev,
)
if err != nil {
return err
}
err = s.publisher.Publish(ctx, e)
if err != nil {
return err
}
return experimentStorage.CreateExperiment(ctxWithTx, experiment, req.EnvironmentId)
})
if err != nil {
if errors.Is(err, v2es.ErrExperimentAlreadyExists) {
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 experiment",
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.CreateExperimentResponse{
Experiment: experiment.Experiment,
}, nil
}

func validateCreateExperimentRequest(req *proto.CreateExperimentRequest, localizer locale.Localizer) error {
if req.Command.FeatureId == "" {
dt, err := statusFeatureIDRequired.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Expand Down Expand Up @@ -440,6 +576,58 @@ func validateCreateExperimentRequest(req *proto.CreateExperimentRequest, localiz
return nil
}

func validateCreateExperimentRequestNoCommand(
req *proto.CreateExperimentRequest,
localizer locale.Localizer,
) error {
if req.FeatureId == "" {
dt, err := statusFeatureIDRequired.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalizeWithTemplate(locale.RequiredFieldTemplate, "feature_id"),
})
if err != nil {
return statusInternal.Err()
}
return dt.Err()
}
if len(req.GoalIds) == 0 {
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()
}
for _, gid := range req.GoalIds {
if gid == "" {
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 err := validateExperimentPeriod(req.StartAt, req.StopAt, localizer); err != nil {
return err
}
if req.Name == "" {
dt, err := statusExperimentNameRequired.WithDetails(&errdetails.LocalizedMessage{
Locale: localizer.GetLocale(),
Message: localizer.MustLocalizeWithTemplate(locale.RequiredFieldTemplate, "name"),
})
if err != nil {
return statusInternal.Err()
}
return dt.Err()
}
return nil
}

func validateExperimentPeriod(startAt, stopAt int64, localizer locale.Localizer) error {
period := stopAt - startAt
if period <= 0 || period > int64(maxExperimentPeriod) {
Expand Down
Loading

0 comments on commit 8b34aa4

Please sign in to comment.