From 0aa0f85c597ec82198c94053782db03a03a30120 Mon Sep 17 00:00:00 2001 From: Mike Cutalo Date: Thu, 3 Mar 2022 13:03:26 -0800 Subject: [PATCH] shortlink: backend implementation (#2074) --- .../service/shortlink/v1/shortlink.proto | 14 ++ .../service/shortlink/v1/shortlink.pb.go | 165 ++++++++++++++ .../shortlink/v1/shortlink.pb.validate.go | 138 ++++++++++++ backend/gateway/core.go | 4 + backend/module/shortlink/shortlink.go | 19 +- backend/service/shortlink/shortlink.go | 133 ++++++++++- backend/service/shortlink/shortlink_test.go | 212 ++++++++++++++++++ frontend/api/src/index.d.ts | 62 +++++ frontend/api/src/index.js | 156 +++++++++++++ 9 files changed, 895 insertions(+), 8 deletions(-) create mode 100644 api/config/service/shortlink/v1/shortlink.proto create mode 100644 backend/api/config/service/shortlink/v1/shortlink.pb.go create mode 100644 backend/api/config/service/shortlink/v1/shortlink.pb.validate.go create mode 100644 backend/service/shortlink/shortlink_test.go diff --git a/api/config/service/shortlink/v1/shortlink.proto b/api/config/service/shortlink/v1/shortlink.proto new file mode 100644 index 0000000000..01b1e148be --- /dev/null +++ b/api/config/service/shortlink/v1/shortlink.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package clutch.config.service.shortlink.v1; + +option go_package = "github.com/lyft/clutch/backend/api/config/service/shortlink/v1;shortlinkv1"; + +message Config { + // Chars is the list of characters that will be used when generating the random string. + // By default its set to [a-zA-Z0-9] + string shortlink_chars = 1; + // This sets the length of the random string being generated. + // By default its set to 10 + int64 shortlink_length = 2; +} diff --git a/backend/api/config/service/shortlink/v1/shortlink.pb.go b/backend/api/config/service/shortlink/v1/shortlink.pb.go new file mode 100644 index 0000000000..86a4b4c783 --- /dev/null +++ b/backend/api/config/service/shortlink/v1/shortlink.pb.go @@ -0,0 +1,165 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.27.1 +// protoc v3.17.3 +// source: config/service/shortlink/v1/shortlink.proto + +package shortlinkv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Config struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Chars is the list of characters that will be used when generating the random string. + // By default its set to [a-zA-Z0-9] + ShortlinkChars string `protobuf:"bytes,1,opt,name=shortlink_chars,json=shortlinkChars,proto3" json:"shortlink_chars,omitempty"` + // This sets the length of the random string being generated. + // By default its set to 10 + ShortlinkLength int64 `protobuf:"varint,2,opt,name=shortlink_length,json=shortlinkLength,proto3" json:"shortlink_length,omitempty"` +} + +func (x *Config) Reset() { + *x = Config{} + if protoimpl.UnsafeEnabled { + mi := &file_config_service_shortlink_v1_shortlink_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_config_service_shortlink_v1_shortlink_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_config_service_shortlink_v1_shortlink_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetShortlinkChars() string { + if x != nil { + return x.ShortlinkChars + } + return "" +} + +func (x *Config) GetShortlinkLength() int64 { + if x != nil { + return x.ShortlinkLength + } + return 0 +} + +var File_config_service_shortlink_v1_shortlink_proto protoreflect.FileDescriptor + +var file_config_service_shortlink_v1_shortlink_proto_rawDesc = []byte{ + 0x0a, 0x2b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x2f, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x6c, 0x69, 0x6e, 0x6b, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x68, + 0x6f, 0x72, 0x74, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x22, 0x63, + 0x6c, 0x75, 0x74, 0x63, 0x68, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x2e, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x76, + 0x31, 0x22, 0x5c, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x27, 0x0a, 0x0f, 0x73, + 0x68, 0x6f, 0x72, 0x74, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x68, 0x61, 0x72, 0x73, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x6c, 0x69, 0x6e, 0x6b, 0x43, + 0x68, 0x61, 0x72, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x6c, 0x69, 0x6e, + 0x6b, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, + 0x73, 0x68, 0x6f, 0x72, 0x74, 0x6c, 0x69, 0x6e, 0x6b, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, + 0x4c, 0x5a, 0x4a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x79, + 0x66, 0x74, 0x2f, 0x63, 0x6c, 0x75, 0x74, 0x63, 0x68, 0x2f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, + 0x64, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2f, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x2f, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x6c, 0x69, 0x6e, 0x6b, 0x2f, 0x76, + 0x31, 0x3b, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x6c, 0x69, 0x6e, 0x6b, 0x76, 0x31, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_config_service_shortlink_v1_shortlink_proto_rawDescOnce sync.Once + file_config_service_shortlink_v1_shortlink_proto_rawDescData = file_config_service_shortlink_v1_shortlink_proto_rawDesc +) + +func file_config_service_shortlink_v1_shortlink_proto_rawDescGZIP() []byte { + file_config_service_shortlink_v1_shortlink_proto_rawDescOnce.Do(func() { + file_config_service_shortlink_v1_shortlink_proto_rawDescData = protoimpl.X.CompressGZIP(file_config_service_shortlink_v1_shortlink_proto_rawDescData) + }) + return file_config_service_shortlink_v1_shortlink_proto_rawDescData +} + +var file_config_service_shortlink_v1_shortlink_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_config_service_shortlink_v1_shortlink_proto_goTypes = []interface{}{ + (*Config)(nil), // 0: clutch.config.service.shortlink.v1.Config +} +var file_config_service_shortlink_v1_shortlink_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_config_service_shortlink_v1_shortlink_proto_init() } +func file_config_service_shortlink_v1_shortlink_proto_init() { + if File_config_service_shortlink_v1_shortlink_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_config_service_shortlink_v1_shortlink_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Config); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_config_service_shortlink_v1_shortlink_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_config_service_shortlink_v1_shortlink_proto_goTypes, + DependencyIndexes: file_config_service_shortlink_v1_shortlink_proto_depIdxs, + MessageInfos: file_config_service_shortlink_v1_shortlink_proto_msgTypes, + }.Build() + File_config_service_shortlink_v1_shortlink_proto = out.File + file_config_service_shortlink_v1_shortlink_proto_rawDesc = nil + file_config_service_shortlink_v1_shortlink_proto_goTypes = nil + file_config_service_shortlink_v1_shortlink_proto_depIdxs = nil +} diff --git a/backend/api/config/service/shortlink/v1/shortlink.pb.validate.go b/backend/api/config/service/shortlink/v1/shortlink.pb.validate.go new file mode 100644 index 0000000000..bbf573a2de --- /dev/null +++ b/backend/api/config/service/shortlink/v1/shortlink.pb.validate.go @@ -0,0 +1,138 @@ +// Code generated by protoc-gen-validate. DO NOT EDIT. +// source: config/service/shortlink/v1/shortlink.proto + +package shortlinkv1 + +import ( + "bytes" + "errors" + "fmt" + "net" + "net/mail" + "net/url" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + + "google.golang.org/protobuf/types/known/anypb" +) + +// ensure the imports are used +var ( + _ = bytes.MinRead + _ = errors.New("") + _ = fmt.Print + _ = utf8.UTFMax + _ = (*regexp.Regexp)(nil) + _ = (*strings.Reader)(nil) + _ = net.IPv4len + _ = time.Duration(0) + _ = (*url.URL)(nil) + _ = (*mail.Address)(nil) + _ = anypb.Any{} + _ = sort.Sort +) + +// Validate checks the field values on Config with the rules defined in the +// proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *Config) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on Config with the rules defined in the +// proto definition for this message. If any rules are violated, the result is +// a list of violation errors wrapped in ConfigMultiError, or nil if none found. +func (m *Config) ValidateAll() error { + return m.validate(true) +} + +func (m *Config) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for ShortlinkChars + + // no validation rules for ShortlinkLength + + if len(errors) > 0 { + return ConfigMultiError(errors) + } + + return nil +} + +// ConfigMultiError is an error wrapping multiple validation errors returned by +// Config.ValidateAll() if the designated constraints aren't met. +type ConfigMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ConfigMultiError) Error() string { + var msgs []string + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ConfigMultiError) AllErrors() []error { return m } + +// ConfigValidationError is the validation error returned by Config.Validate if +// the designated constraints aren't met. +type ConfigValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ConfigValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ConfigValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ConfigValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ConfigValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ConfigValidationError) ErrorName() string { return "ConfigValidationError" } + +// Error satisfies the builtin error interface +func (e ConfigValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sConfig.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ConfigValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ConfigValidationError{} diff --git a/backend/gateway/core.go b/backend/gateway/core.go index 8bd0b51099..7e79a27ef7 100644 --- a/backend/gateway/core.go +++ b/backend/gateway/core.go @@ -27,6 +27,7 @@ import ( kinesismod "github.com/lyft/clutch/backend/module/kinesis" proxymod "github.com/lyft/clutch/backend/module/proxy" resolvermod "github.com/lyft/clutch/backend/module/resolver" + shortlinkmod "github.com/lyft/clutch/backend/module/shortlink" "github.com/lyft/clutch/backend/module/sourcecontrol" topologymod "github.com/lyft/clutch/backend/module/topology" "github.com/lyft/clutch/backend/resolver" @@ -48,6 +49,7 @@ import ( feedbackservice "github.com/lyft/clutch/backend/service/feedback" "github.com/lyft/clutch/backend/service/github" k8sservice "github.com/lyft/clutch/backend/service/k8s" + shortlinkservice "github.com/lyft/clutch/backend/service/shortlink" sourcegraphservice "github.com/lyft/clutch/backend/service/sourcegraph" "github.com/lyft/clutch/backend/service/temporal" topologyservice "github.com/lyft/clutch/backend/service/topology" @@ -79,6 +81,7 @@ var Modules = module.Factory{ redisexperimentation.Name: redisexperimentation.New, resolvermod.Name: resolvermod.New, serverexperimentation.Name: serverexperimentation.New, + shortlinkmod.Name: shortlinkmod.New, slackbotmod.Name: slackbotmod.New, sourcecontrol.Name: sourcecontrol.New, topologymod.Name: topologymod.New, @@ -99,6 +102,7 @@ var Services = service.Factory{ k8sservice.Name: k8sservice.New, loggingsink.Name: loggingsink.New, pgservice.Name: pgservice.New, + shortlinkservice.Name: shortlinkservice.New, slack.Name: slack.New, sourcegraphservice.Name: sourcegraphservice.New, temporal.Name: temporal.New, diff --git a/backend/module/shortlink/shortlink.go b/backend/module/shortlink/shortlink.go index 7399f7615c..22a54e087a 100644 --- a/backend/module/shortlink/shortlink.go +++ b/backend/module/shortlink/shortlink.go @@ -56,9 +56,24 @@ func newShortlinkAPI(svc shortlink.Service) shortlinkv1.ShortlinkAPIServer { } func (s *shortlinkAPI) Create(ctx context.Context, req *shortlinkv1.CreateRequest) (*shortlinkv1.CreateResponse, error) { - return nil, errors.New("not implemented") + hash, err := s.shortlink.Create(ctx, req.Path, req.State) + if err != nil { + return nil, err + } + + return &shortlinkv1.CreateResponse{ + Hash: hash, + }, nil } func (s *shortlinkAPI) Get(ctx context.Context, req *shortlinkv1.GetRequest) (*shortlinkv1.GetResponse, error) { - return nil, errors.New("not implemented") + path, state, err := s.shortlink.Get(ctx, req.Hash) + if err != nil { + return nil, err + } + + return &shortlinkv1.GetResponse{ + Path: path, + State: state, + }, nil } diff --git a/backend/service/shortlink/shortlink.go b/backend/service/shortlink/shortlink.go index dbcdfb0d96..b4c63c4070 100644 --- a/backend/service/shortlink/shortlink.go +++ b/backend/service/shortlink/shortlink.go @@ -2,20 +2,33 @@ package shortlink import ( "context" + "crypto/rand" "database/sql" "errors" + sq "github.com/Masterminds/squirrel" "github.com/golang/protobuf/ptypes/any" + "github.com/lib/pq" "github.com/uber-go/tally/v4" "go.uber.org/zap" + "google.golang.org/protobuf/encoding/protojson" + shortlinkv1cfg "github.com/lyft/clutch/backend/api/config/service/shortlink/v1" shortlinkv1 "github.com/lyft/clutch/backend/api/shortlink/v1" "github.com/lyft/clutch/backend/service" pgservice "github.com/lyft/clutch/backend/service/db/postgres" ) const ( - Name = "clutch.service.shortlink" + Name = "clutch.service.shortlink" + defaultShortlinkChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + defaultShortlinkLength = 10 + maxCollisionRetry = 5 + + // If we hit a key collision inserting a duplicate random string this error is thrown. + // We catch this error and retry up to the maxCollisionRetry limit. + // https://www.postgresql.org/docs/9.3/errcodes-appendix.html + pgUniqueErrorCode = "23505" ) type Service interface { @@ -24,12 +37,21 @@ type Service interface { } type client struct { + shortlinkChars string + shortlinkLength int + db *sql.DB log *zap.Logger scope tally.Scope } func New(cfg *any.Any, logger *zap.Logger, scope tally.Scope) (service.Service, error) { + slConfig := &shortlinkv1cfg.Config{} + err := cfg.UnmarshalTo(slConfig) + if err != nil { + return nil, err + } + p, ok := service.Registry[pgservice.Name] if !ok { return nil, errors.New("Please config the datastore [clutch.service.db.postgres] to use the shortlink service") @@ -40,19 +62,118 @@ func New(cfg *any.Any, logger *zap.Logger, scope tally.Scope) (service.Service, return nil, errors.New("Unable to get the datastore client") } + chars := defaultShortlinkChars + if slConfig.ShortlinkChars != "" { + chars = slConfig.ShortlinkChars + } + + length := defaultShortlinkLength + if slConfig.ShortlinkLength > 0 { + length = int(slConfig.ShortlinkLength) + } + c := &client{ - db: dbClient.DB(), - log: logger, - scope: scope, + shortlinkChars: chars, + shortlinkLength: length, + db: dbClient.DB(), + log: logger, + scope: scope, } return c, nil } func (c *client) Create(ctx context.Context, path string, state []*shortlinkv1.ShareableState) (string, error) { - return "", errors.New("not implemented") + stateJson, err := marshalShareableState(state) + if err != nil { + return "", err + } + + return c.createShortlinkWithRetries(ctx, path, stateJson) +} + +// createShortlinkWithRetries retries the insert of a new shortlink +// This function generates a shortlink hash which is used as the primary key in the shortlink table +// There could be a possibility of a collision depending on the configuration +// With the default settings in place [a-zA-Z0-9] and a default subset length of 10, +// this leaves us with 62^10. +func (c *client) createShortlinkWithRetries(ctx context.Context, path string, state []byte) (string, error) { + for i := 0; i < maxCollisionRetry; i++ { + hash, err := generateShortlink(c.shortlinkChars, c.shortlinkLength) + if err != nil { + return "", err + } + + insertBuilder := sq.StatementBuilder.PlaceholderFormat(sq.Dollar). + Insert("shortlink"). + Columns("slhash", "page_path", "state"). + Values(hash, path, state) + + _, err = insertBuilder.RunWith(c.db).ExecContext(ctx) + if err, ok := err.(*pq.Error); ok { + if err.Code == pgUniqueErrorCode { + // If we hit a key collision lets retry + continue + } else { + return "", err + } + } + + return hash, err + } + + return "", errors.New("retries exhausted, unable to create unique shortlink hash.") } func (c *client) Get(ctx context.Context, hash string) (string, []*shortlinkv1.ShareableState, error) { - return "", nil, errors.New("not implemented") + query := sq.StatementBuilder.PlaceholderFormat(sq.Dollar). + Select("page_path", "state"). + From("shortlink"). + Where(sq.Eq{"slhash": hash}) + + row := query.RunWith(c.db).QueryRowContext(ctx) + + var path string + var byteState []byte + if err := row.Scan(&path, &byteState); err != nil { + c.log.Error("Error scanning row", zap.Error(err)) + return "", nil, err + } + + var state shortlinkv1.CreateRequest + if err := protojson.Unmarshal(byteState, &state); err != nil { + c.log.Error("Error unmarshaling data field", zap.Error(err)) + return "", nil, err + } + + return path, state.State, nil +} + +// generateShortlink generates a random string from a set of characters to the length specified +func generateShortlink(chars string, length int) (string, error) { + if len(chars) == 0 || length == 0 { + return "", errors.New("chars or length are invalid lengths") + } + + res := make([]byte, length) + _, err := rand.Read(res) + if err != nil { + return "", err + } + + for i := 0; i < length; i++ { + res[i] = chars[int(res[i])%len(chars)] + } + + return string(res), nil +} + +func marshalShareableState(state []*shortlinkv1.ShareableState) ([]byte, error) { + stateJson, err := protojson.Marshal(&shortlinkv1.CreateRequest{ + State: state, + }) + if err != nil { + return nil, err + } + return stateJson, nil } diff --git a/backend/service/shortlink/shortlink_test.go b/backend/service/shortlink/shortlink_test.go new file mode 100644 index 0000000000..7b5bfbf2e0 --- /dev/null +++ b/backend/service/shortlink/shortlink_test.go @@ -0,0 +1,212 @@ +package shortlink + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/uber-go/tally/v4" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" + + shortlinkv1cfg "github.com/lyft/clutch/backend/api/config/service/shortlink/v1" + shortlinkv1 "github.com/lyft/clutch/backend/api/shortlink/v1" + "github.com/lyft/clutch/backend/mock/service/dbmock" + "github.com/lyft/clutch/backend/service" +) + +func TestNewDefaults(t *testing.T) { + service.Registry["clutch.service.db.postgres"] = dbmock.NewMockDB() + cfg := &shortlinkv1cfg.Config{} + + anycfg, err := anypb.New(cfg) + assert.NoError(t, err) + + c, err := New(anycfg, zap.NewNop(), tally.NoopScope) + assert.NoError(t, err) + + slClient := c.(*client) + assert.Equal(t, defaultShortlinkChars, slClient.shortlinkChars) + assert.Equal(t, defaultShortlinkLength, slClient.shortlinkLength) +} + +func TestNewWithOverrides(t *testing.T) { + service.Registry["clutch.service.db.postgres"] = dbmock.NewMockDB() + cfg := &shortlinkv1cfg.Config{ + ShortlinkChars: "abc", + ShortlinkLength: 3, + } + + anycfg, err := anypb.New(cfg) + assert.NoError(t, err) + + c, err := New(anycfg, zap.NewNop(), tally.NoopScope) + assert.NoError(t, err) + + slClient := c.(*client) + assert.Equal(t, "abc", slClient.shortlinkChars) + assert.Equal(t, 3, slClient.shortlinkLength) +} + +func TestGetShortlink(t *testing.T) { + m := dbmock.NewMockDB() + m.Register() + + slClient := &client{ + shortlinkChars: "a", + shortlinkLength: 1, + db: m.DB(), + log: zap.NewNop(), + } + + expectedState := []*shortlinkv1.ShareableState{ + { + Key: "mock", + State: &structpb.Value{ + Kind: &structpb.Value_StringValue{StringValue: "mock string"}, + }, + }, + } + + stateJson, err := marshalShareableState(expectedState) + assert.NoError(t, err) + + rows := sqlmock.NewRows([]string{"page_path", "state"}) + rows.AddRow("/test", stateJson) + + m.Mock.ExpectQuery("SELECT page_path, state FROM shortlink WHERE slhash = .*"). + WillReturnRows(rows) + + path, actualState, err := slClient.Get(context.TODO(), "test") + assert.NoError(t, err) + assert.Equal(t, "/test", path) + assert.Equal(t, expectedState, actualState) + m.MustMeetExpectations() +} + +func TestCreateShortlinkWithRetries(t *testing.T) { + m := dbmock.NewMockDB() + m.Register() + + slClient := &client{ + shortlinkChars: "a", + shortlinkLength: 1, + db: m.DB(), + } + + m.Mock.ExpectExec("INSERT INTO shortlink").WithArgs( + "a", "/test", []byte("state"), + ).WillReturnResult(sqlmock.NewResult(1, 1)) + + hash, err := slClient.createShortlinkWithRetries(context.TODO(), "/test", []byte("state")) + assert.NoError(t, err) + assert.NotNil(t, hash) + m.MustMeetExpectations() +} + +func TestGenerateShortlink(t *testing.T) { + tests := []struct { + name string + inputChars string + inputLength int + expectLength int + shouldError bool + }{ + { + name: "lower alpha 10 len", + inputChars: "abcdefghijklmnopqrstuvwxyz", + inputLength: 10, + expectLength: 10, + shouldError: false, + }, + { + name: "zero len", + inputChars: "", + inputLength: 10, + expectLength: 0, + shouldError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + hash, err := generateShortlink(test.inputChars, test.inputLength) + if test.shouldError { + assert.Error(t, err) + assert.Empty(t, hash) + } else { + assert.NoError(t, err) + assert.Len(t, hash, test.expectLength) + } + }) + } +} + +func TestProtoAnyForState(t *testing.T) { + tests := []struct { + name string + expect string + input []*shortlinkv1.ShareableState + }{ + { + name: "string value", + expect: `{"state":[{"key":"mock","state":"mock string"}]}`, + input: []*shortlinkv1.ShareableState{ + { + Key: "mock", + State: &structpb.Value{ + Kind: &structpb.Value_StringValue{StringValue: "mock string"}, + }, + }, + }, + }, + { + name: "numbers", + expect: `{"state":[{"key":"mock","state":{"key":123,"key1":345}}]}`, + input: []*shortlinkv1.ShareableState{ + { + Key: "mock", + State: &structpb.Value{ + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "key": structpb.NewNumberValue(123), + "key1": structpb.NewNumberValue(345), + }, + }, + }, + }, + }, + }, + }, + { + name: "nested object", + expect: `{"state":[{"key":"mock","state":{"key":true,"key1":"value"}}]}`, + input: []*shortlinkv1.ShareableState{ + { + Key: "mock", + State: &structpb.Value{ + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "key": structpb.NewBoolValue(true), + "key1": structpb.NewStringValue("value"), + }, + }, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + marshal, err := marshalShareableState(test.input) + assert.NoError(t, err) + assert.Equal(t, test.expect, string(marshal)) + }) + } +} diff --git a/frontend/api/src/index.d.ts b/frontend/api/src/index.d.ts index de20c15871..613469f5ec 100644 --- a/frontend/api/src/index.d.ts +++ b/frontend/api/src/index.d.ts @@ -10031,6 +10031,68 @@ export namespace clutch { } } + /** Namespace shortlink. */ + namespace shortlink { + + /** Namespace v1. */ + namespace v1 { + + /** Properties of a Config. */ + interface IConfig { + + /** Config shortlinkChars */ + shortlinkChars?: (string|null); + + /** Config shortlinkLength */ + shortlinkLength?: (number|Long|null); + } + + /** Represents a Config. */ + class Config implements IConfig { + + /** + * Constructs a new Config. + * @param [properties] Properties to set + */ + constructor(properties?: clutch.config.service.shortlink.v1.IConfig); + + /** Config shortlinkChars. */ + public shortlinkChars: string; + + /** Config shortlinkLength. */ + public shortlinkLength: (number|Long); + + /** + * Verifies a Config message. + * @param message Plain object to verify + * @returns `null` if valid, otherwise the reason why it is not + */ + public static verify(message: { [k: string]: any }): (string|null); + + /** + * Creates a Config message from a plain object. Also converts values to their respective internal types. + * @param object Plain object + * @returns Config + */ + public static fromObject(object: { [k: string]: any }): clutch.config.service.shortlink.v1.Config; + + /** + * Creates a plain object from a Config message. Also converts values to other types if specified. + * @param message Config + * @param [options] Conversion options + * @returns Plain object + */ + public static toObject(message: clutch.config.service.shortlink.v1.Config, options?: $protobuf.IConversionOptions): { [k: string]: any }; + + /** + * Converts this Config to JSON. + * @returns JSON object + */ + public toJSON(): { [k: string]: any }; + } + } + } + /** Namespace sourcegraph. */ namespace sourcegraph { diff --git a/frontend/api/src/index.js b/frontend/api/src/index.js index e1daaa2cbe..516a45cb19 100644 --- a/frontend/api/src/index.js +++ b/frontend/api/src/index.js @@ -24397,6 +24397,162 @@ export const clutch = $root.clutch = (() => { return k8s; })(); + service.shortlink = (function() { + + /** + * Namespace shortlink. + * @memberof clutch.config.service + * @namespace + */ + const shortlink = {}; + + shortlink.v1 = (function() { + + /** + * Namespace v1. + * @memberof clutch.config.service.shortlink + * @namespace + */ + const v1 = {}; + + v1.Config = (function() { + + /** + * Properties of a Config. + * @memberof clutch.config.service.shortlink.v1 + * @interface IConfig + * @property {string|null} [shortlinkChars] Config shortlinkChars + * @property {number|Long|null} [shortlinkLength] Config shortlinkLength + */ + + /** + * Constructs a new Config. + * @memberof clutch.config.service.shortlink.v1 + * @classdesc Represents a Config. + * @implements IConfig + * @constructor + * @param {clutch.config.service.shortlink.v1.IConfig=} [properties] Properties to set + */ + function Config(properties) { + if (properties) + for (let keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * Config shortlinkChars. + * @member {string} shortlinkChars + * @memberof clutch.config.service.shortlink.v1.Config + * @instance + */ + Config.prototype.shortlinkChars = ""; + + /** + * Config shortlinkLength. + * @member {number|Long} shortlinkLength + * @memberof clutch.config.service.shortlink.v1.Config + * @instance + */ + Config.prototype.shortlinkLength = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * Verifies a Config message. + * @function verify + * @memberof clutch.config.service.shortlink.v1.Config + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + Config.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.shortlinkChars != null && message.hasOwnProperty("shortlinkChars")) + if (!$util.isString(message.shortlinkChars)) + return "shortlinkChars: string expected"; + if (message.shortlinkLength != null && message.hasOwnProperty("shortlinkLength")) + if (!$util.isInteger(message.shortlinkLength) && !(message.shortlinkLength && $util.isInteger(message.shortlinkLength.low) && $util.isInteger(message.shortlinkLength.high))) + return "shortlinkLength: integer|Long expected"; + return null; + }; + + /** + * Creates a Config message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof clutch.config.service.shortlink.v1.Config + * @static + * @param {Object.} object Plain object + * @returns {clutch.config.service.shortlink.v1.Config} Config + */ + Config.fromObject = function fromObject(object) { + if (object instanceof $root.clutch.config.service.shortlink.v1.Config) + return object; + let message = new $root.clutch.config.service.shortlink.v1.Config(); + if (object.shortlinkChars != null) + message.shortlinkChars = String(object.shortlinkChars); + if (object.shortlinkLength != null) + if ($util.Long) + (message.shortlinkLength = $util.Long.fromValue(object.shortlinkLength)).unsigned = false; + else if (typeof object.shortlinkLength === "string") + message.shortlinkLength = parseInt(object.shortlinkLength, 10); + else if (typeof object.shortlinkLength === "number") + message.shortlinkLength = object.shortlinkLength; + else if (typeof object.shortlinkLength === "object") + message.shortlinkLength = new $util.LongBits(object.shortlinkLength.low >>> 0, object.shortlinkLength.high >>> 0).toNumber(); + return message; + }; + + /** + * Creates a plain object from a Config message. Also converts values to other types if specified. + * @function toObject + * @memberof clutch.config.service.shortlink.v1.Config + * @static + * @param {clutch.config.service.shortlink.v1.Config} message Config + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + Config.toObject = function toObject(message, options) { + if (!options) + options = {}; + let object = {}; + if (options.defaults) { + object.shortlinkChars = ""; + if ($util.Long) { + let long = new $util.Long(0, 0, false); + object.shortlinkLength = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.shortlinkLength = options.longs === String ? "0" : 0; + } + if (message.shortlinkChars != null && message.hasOwnProperty("shortlinkChars")) + object.shortlinkChars = message.shortlinkChars; + if (message.shortlinkLength != null && message.hasOwnProperty("shortlinkLength")) + if (typeof message.shortlinkLength === "number") + object.shortlinkLength = options.longs === String ? String(message.shortlinkLength) : message.shortlinkLength; + else + object.shortlinkLength = options.longs === String ? $util.Long.prototype.toString.call(message.shortlinkLength) : options.longs === Number ? new $util.LongBits(message.shortlinkLength.low >>> 0, message.shortlinkLength.high >>> 0).toNumber() : message.shortlinkLength; + return object; + }; + + /** + * Converts this Config to JSON. + * @function toJSON + * @memberof clutch.config.service.shortlink.v1.Config + * @instance + * @returns {Object.} JSON object + */ + Config.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return Config; + })(); + + return v1; + })(); + + return shortlink; + })(); + service.sourcegraph = (function() { /**