From 77c4cc8185dbc4ecf6b6a698a6f1662a181f6126 Mon Sep 17 00:00:00 2001 From: adlerhurst <27845747+adlerhurst@users.noreply.github.com> Date: Wed, 26 Mar 2025 16:08:02 +0100 Subject: [PATCH] command pattern --- backend/command/command/command.go | 22 +++++ backend/command/command/database.go | 1 + backend/command/command/domain.go | 33 +++++++ backend/command/command/instance.go | 97 +++++++++++++++++++ backend/command/command/logging.go | 37 +++++++ backend/command/command/tracing.go | 22 +++++ backend/command/command/user.go | 46 +++++++++ backend/command/invoker/api.go | 38 ++++++++ backend/command/query/instance.go | 32 ++++++ backend/command/query/query.go | 8 ++ backend/command/receiver/db/instance.go | 58 +++++++++++ backend/command/receiver/domain.go | 6 ++ backend/command/receiver/email.go | 7 ++ backend/command/receiver/instance.go | 28 ++++++ backend/command/receiver/phone.go | 7 ++ backend/command/receiver/user.go | 9 ++ backend/command/receiver/verifiable.go | 8 ++ backend/storage/database/database.go | 2 +- backend/storage/database/dialect/gosql/tx.go | 2 +- .../storage/database/dialect/postgres/tx.go | 3 +- backend/storage/database/mock/transaction.go | 2 +- 21 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 backend/command/command/command.go create mode 100644 backend/command/command/database.go create mode 100644 backend/command/command/domain.go create mode 100644 backend/command/command/instance.go create mode 100644 backend/command/command/logging.go create mode 100644 backend/command/command/tracing.go create mode 100644 backend/command/command/user.go create mode 100644 backend/command/invoker/api.go create mode 100644 backend/command/query/instance.go create mode 100644 backend/command/query/query.go create mode 100644 backend/command/receiver/db/instance.go create mode 100644 backend/command/receiver/domain.go create mode 100644 backend/command/receiver/email.go create mode 100644 backend/command/receiver/instance.go create mode 100644 backend/command/receiver/phone.go create mode 100644 backend/command/receiver/user.go create mode 100644 backend/command/receiver/verifiable.go diff --git a/backend/command/command/command.go b/backend/command/command/command.go new file mode 100644 index 0000000000..478fd23b55 --- /dev/null +++ b/backend/command/command/command.go @@ -0,0 +1,22 @@ +package command + +import "context" + +type Command interface { + Execute(context.Context) error + Name() string +} + +type Batch struct { + commands []Command +} + +func (b *Batch) Execute(ctx context.Context) error { + for _, command := range b.commands { + if err := command.Execute(ctx); err != nil { + // TODO: undo? + return err + } + } + return nil +} diff --git a/backend/command/command/database.go b/backend/command/command/database.go new file mode 100644 index 0000000000..d47dcf0d9b --- /dev/null +++ b/backend/command/command/database.go @@ -0,0 +1 @@ +package command diff --git a/backend/command/command/domain.go b/backend/command/command/domain.go new file mode 100644 index 0000000000..ba6b596b8a --- /dev/null +++ b/backend/command/command/domain.go @@ -0,0 +1,33 @@ +package command + +import ( + "slices" + + "github.com/zitadel/zitadel/backend/command/receiver" +) + +type SetPrimaryDomain struct { + Domains []*receiver.Domain + + Domain string +} + +func (s *SetPrimaryDomain) Execute() error { + for domain := range slices.Values(s.Domains) { + domain.IsPrimary = domain.Name == s.Domain + } + return nil +} + +type RemoveDomain struct { + Domains []*receiver.Domain + + Domain string +} + +func (r *RemoveDomain) Execute() error { + r.Domains = slices.DeleteFunc(r.Domains, func(domain *receiver.Domain) bool { + return domain.Name == r.Domain + }) + return nil +} diff --git a/backend/command/command/instance.go b/backend/command/command/instance.go new file mode 100644 index 0000000000..f019b29da6 --- /dev/null +++ b/backend/command/command/instance.go @@ -0,0 +1,97 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/backend/command/receiver" +) + +type createInstance struct { + receiver receiver.InstanceManipulator + *receiver.Instance +} + +func CreateInstance(receiver receiver.InstanceManipulator, instance *receiver.Instance) *createInstance { + return &createInstance{ + Instance: instance, + receiver: receiver, + } +} + +func (c *createInstance) Execute(ctx context.Context) error { + c.State = receiver.InstanceStateActive + return c.receiver.Create(ctx, c.Instance) +} + +func (c *createInstance) Name() string { + return "CreateInstance" +} + +type deleteInstance struct { + receiver receiver.InstanceManipulator + *receiver.Instance +} + +func DeleteInstance(receiver receiver.InstanceManipulator, instance *receiver.Instance) *deleteInstance { + return &deleteInstance{ + Instance: instance, + receiver: receiver, + } +} + +func (d *deleteInstance) Execute(ctx context.Context) error { + return d.receiver.Delete(ctx, d.Instance) +} + +func (c *deleteInstance) Name() string { + return "DeleteInstance" +} + +type updateInstance struct { + receiver receiver.InstanceManipulator + + *receiver.Instance + + name string +} + +func UpdateInstance(receiver receiver.InstanceManipulator, instance *receiver.Instance, name string) *updateInstance { + return &updateInstance{ + Instance: instance, + receiver: receiver, + name: name, + } +} + +func (u *updateInstance) Execute(ctx context.Context) error { + u.Instance.Name = u.name + // return u.receiver.(ctx, u.Instance) + return nil +} + +func (c *updateInstance) Name() string { + return "UpdateInstance" +} + +type addDomain struct { + receiver receiver.InstanceManipulator + + *receiver.Instance + *receiver.Domain +} + +func AddDomain(receiver receiver.InstanceManipulator, instance *receiver.Instance, domain *receiver.Domain) *addDomain { + return &addDomain{ + Instance: instance, + Domain: domain, + receiver: receiver, + } +} + +func (a *addDomain) Execute(ctx context.Context) error { + return a.receiver.AddDomain(ctx, a.Instance, a.Domain) +} + +func (c *addDomain) Name() string { + return "AddDomain" +} diff --git a/backend/command/command/logging.go b/backend/command/command/logging.go new file mode 100644 index 0000000000..27721079d0 --- /dev/null +++ b/backend/command/command/logging.go @@ -0,0 +1,37 @@ +package command + +import ( + "context" + "log/slog" + "time" + + "github.com/zitadel/zitadel/backend/telemetry/logging" +) + +type Logger struct { + level slog.Level + *logging.Logger + cmd Command +} + +func Activity(l *logging.Logger, command Command) *Logger { + return &Logger{ + Logger: l.With(slog.String("type", "activity")), + level: slog.LevelInfo, + cmd: command, + } +} + +func (l *Logger) Execute(ctx context.Context) error { + start := time.Now() + log := l.Logger.With(slog.String("command", l.cmd.Name())) + log.InfoContext(ctx, "execute") + err := l.cmd.Execute(ctx) + log = log.With(slog.Duration("took", time.Since(start))) + if err != nil { + log.Log(ctx, l.level, "failed", slog.Any("cause", err)) + return err + } + log.Log(ctx, l.level, "successful") + return nil +} diff --git a/backend/command/command/tracing.go b/backend/command/command/tracing.go new file mode 100644 index 0000000000..9dc3ec73a4 --- /dev/null +++ b/backend/command/command/tracing.go @@ -0,0 +1,22 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/backend/telemetry/tracing" +) + +type Trace struct { + command Command + tracer *tracing.Tracer +} + +func (t *Trace) Execute(ctx context.Context) error { + ctx, span := t.tracer.Start(ctx, t.command.Name()) + defer span.End() + err := t.command.Execute(ctx) + if err != nil { + span.RecordError(err) + } + return err +} diff --git a/backend/command/command/user.go b/backend/command/command/user.go new file mode 100644 index 0000000000..aea56dc36b --- /dev/null +++ b/backend/command/command/user.go @@ -0,0 +1,46 @@ +package command + +import "github.com/zitadel/zitadel/backend/command/receiver" + +type ChangeUsername struct { + *receiver.User + + Username string +} + +func (c *ChangeUsername) Execute() error { + c.User.Username = c.Username + return nil +} + +func (c *ChangeUsername) Name() string { + return "ChangeUsername" +} + +type SetEmail struct { + *receiver.User + *receiver.Email +} + +func (s *SetEmail) Execute() error { + s.User.Email = s.Email + return nil +} + +func (s *SetEmail) Name() string { + return "SetEmail" +} + +type SetPhone struct { + *receiver.User + *receiver.Phone +} + +func (s *SetPhone) Execute() error { + s.User.Phone = s.Phone + return nil +} + +func (s *SetPhone) Name() string { + return "SetPhone" +} diff --git a/backend/command/invoker/api.go b/backend/command/invoker/api.go new file mode 100644 index 0000000000..7e04ee4507 --- /dev/null +++ b/backend/command/invoker/api.go @@ -0,0 +1,38 @@ +package invoker + +import ( + "context" + + "github.com/zitadel/zitadel/backend/command/command" + "github.com/zitadel/zitadel/backend/command/query" + "github.com/zitadel/zitadel/backend/command/receiver" + "github.com/zitadel/zitadel/backend/command/receiver/db" + "github.com/zitadel/zitadel/backend/storage/database" +) + +type api struct { + db database.Pool + + manipulator receiver.InstanceManipulator + reader receiver.InstanceReader +} + +func (a *api) CreateInstance(ctx context.Context) error { + cmd := command.CreateInstance(db.NewInstance(a.db), &receiver.Instance{ + ID: "123", + Name: "test", + }) + return cmd.Execute(ctx) +} + +func (a *api) DeleteInstance(ctx context.Context) error { + cmd := command.DeleteInstance(db.NewInstance(a.db), &receiver.Instance{ + ID: "123", + }) + return cmd.Execute(ctx) +} + +func (a *api) InstanceByID(ctx context.Context) (*receiver.Instance, error) { + q := query.InstanceByID(a.reader, "123") + return q.Execute(ctx) +} diff --git a/backend/command/query/instance.go b/backend/command/query/instance.go new file mode 100644 index 0000000000..13ee327d42 --- /dev/null +++ b/backend/command/query/instance.go @@ -0,0 +1,32 @@ +package query + +import ( + "context" + + "github.com/zitadel/zitadel/backend/command/receiver" +) + +type instanceByID struct { + receiver receiver.InstanceReader + id string +} + +// InstanceByID returns a new instanceByID query. +func InstanceByID(receiver receiver.InstanceReader, id string) *instanceByID { + return &instanceByID{ + receiver: receiver, + id: id, + } +} + +// Execute implements Query. +func (i *instanceByID) Execute(ctx context.Context) (*receiver.Instance, error) { + return i.receiver.ByID(ctx, i.id) +} + +// Name implements Query. +func (i *instanceByID) Name() string { + return "instanceByID" +} + +var _ Query[*receiver.Instance] = (*instanceByID)(nil) diff --git a/backend/command/query/query.go b/backend/command/query/query.go new file mode 100644 index 0000000000..733b372ac1 --- /dev/null +++ b/backend/command/query/query.go @@ -0,0 +1,8 @@ +package query + +import "context" + +type Query[T any] interface { + Execute(ctx context.Context) (T, error) + Name() string +} diff --git a/backend/command/receiver/db/instance.go b/backend/command/receiver/db/instance.go new file mode 100644 index 0000000000..16122ea0b1 --- /dev/null +++ b/backend/command/receiver/db/instance.go @@ -0,0 +1,58 @@ +package db + +import ( + "context" + + "github.com/zitadel/zitadel/backend/command/receiver" + "github.com/zitadel/zitadel/backend/storage/database" +) + +// NewInstance returns a new instance receiver. +func NewInstance(client database.QueryExecutor) receiver.InstanceManipulator { + return &instance{client: client} +} + +// instance is the sql interface for instances. +type instance struct { + client database.QueryExecutor +} + +// ByID implements receiver.InstanceReader. +func (i *instance) ByID(ctx context.Context, id string) (*receiver.Instance, error) { + var instance receiver.Instance + err := i.client.QueryRow(ctx, "SELECT id, name, state FROM instances WHERE id = $1", id). + Scan( + &instance.ID, + &instance.Name, + &instance.State, + ) + if err != nil { + return nil, err + } + return &instance, nil +} + +// AddDomain implements [receiver.InstanceManipulator]. +func (i *instance) AddDomain(ctx context.Context, instance *receiver.Instance, domain *receiver.Domain) error { + return i.client.Exec(ctx, "INSERT INTO instance_domains (instance_id, domain, is_primary) VALUES ($1, $2, $3)", instance.ID, domain.Name, domain.IsPrimary) +} + +// Create implements [receiver.InstanceManipulator]. +func (i *instance) Create(ctx context.Context, instance *receiver.Instance) error { + return i.client.Exec(ctx, "INSERT INTO instances (id, name, state) VALUES ($1, $2, $3)", instance.ID, instance.Name, instance.State) +} + +// Delete implements [receiver.InstanceManipulator]. +func (i *instance) Delete(ctx context.Context, instance *receiver.Instance) error { + return i.client.Exec(ctx, "DELETE FROM instances WHERE id = $1", instance.ID) +} + +// SetPrimaryDomain implements [receiver.InstanceManipulator]. +func (i *instance) SetPrimaryDomain(ctx context.Context, instance *receiver.Instance, domain *receiver.Domain) error { + return i.client.Exec(ctx, "UPDATE instance_domains SET is_primary = domain = $1 WHERE instance_id = $2", domain.Name, instance.ID) +} + +var ( + _ receiver.InstanceManipulator = (*instance)(nil) + _ receiver.InstanceReader = (*instance)(nil) +) diff --git a/backend/command/receiver/domain.go b/backend/command/receiver/domain.go new file mode 100644 index 0000000000..29bcc59f0a --- /dev/null +++ b/backend/command/receiver/domain.go @@ -0,0 +1,6 @@ +package receiver + +type Domain struct { + Name string + IsPrimary bool +} diff --git a/backend/command/receiver/email.go b/backend/command/receiver/email.go new file mode 100644 index 0000000000..2c16f2cb08 --- /dev/null +++ b/backend/command/receiver/email.go @@ -0,0 +1,7 @@ +package receiver + +type Email struct { + Verifiable + + Address string +} diff --git a/backend/command/receiver/instance.go b/backend/command/receiver/instance.go new file mode 100644 index 0000000000..43be941eda --- /dev/null +++ b/backend/command/receiver/instance.go @@ -0,0 +1,28 @@ +package receiver + +import "context" + +type InstanceState uint8 + +const ( + InstanceStateActive InstanceState = iota + InstanceStateDeleted +) + +type Instance struct { + ID string + Name string + State InstanceState + Domains []*Domain +} + +type InstanceManipulator interface { + Create(ctx context.Context, instance *Instance) error + Delete(ctx context.Context, instance *Instance) error + AddDomain(ctx context.Context, instance *Instance, domain *Domain) error + SetPrimaryDomain(ctx context.Context, instance *Instance, domain *Domain) error +} + +type InstanceReader interface { + ByID(ctx context.Context, id string) (*Instance, error) +} diff --git a/backend/command/receiver/phone.go b/backend/command/receiver/phone.go new file mode 100644 index 0000000000..b92d7bff6d --- /dev/null +++ b/backend/command/receiver/phone.go @@ -0,0 +1,7 @@ +package receiver + +type Phone struct { + Verifiable + + Number string +} diff --git a/backend/command/receiver/user.go b/backend/command/receiver/user.go new file mode 100644 index 0000000000..a2ea0695ee --- /dev/null +++ b/backend/command/receiver/user.go @@ -0,0 +1,9 @@ +package receiver + +type User struct { + ID string + Username string + + Email *Email + Phone *Phone +} diff --git a/backend/command/receiver/verifiable.go b/backend/command/receiver/verifiable.go new file mode 100644 index 0000000000..cf31d77fb8 --- /dev/null +++ b/backend/command/receiver/verifiable.go @@ -0,0 +1,8 @@ +package receiver + +import "github.com/zitadel/zitadel/internal/crypto" + +type Verifiable struct { + IsVerified bool + Code *crypto.CryptoValue +} diff --git a/backend/storage/database/database.go b/backend/storage/database/database.go index c5cee166c2..45d9ece952 100644 --- a/backend/storage/database/database.go +++ b/backend/storage/database/database.go @@ -21,7 +21,7 @@ type Transaction interface { Rollback(ctx context.Context) error End(ctx context.Context, err error) error - Begin(ctx context.Context, opts *TransactionOptions) (Transaction, error) + Begin(ctx context.Context) (Transaction, error) QueryExecutor } diff --git a/backend/storage/database/dialect/gosql/tx.go b/backend/storage/database/dialect/gosql/tx.go index a55f97d0fa..c73fc3b470 100644 --- a/backend/storage/database/dialect/gosql/tx.go +++ b/backend/storage/database/dialect/gosql/tx.go @@ -52,7 +52,7 @@ func (tx *sqlTx) Exec(ctx context.Context, sql string, args ...any) error { // Begin implements [database.Transaction]. // it is unimplemented -func (tx *sqlTx) Begin(ctx context.Context, opts *database.TransactionOptions) (database.Transaction, error) { +func (tx *sqlTx) Begin(ctx context.Context) (database.Transaction, error) { return nil, errors.New("nested transactions are not supported") } diff --git a/backend/storage/database/dialect/postgres/tx.go b/backend/storage/database/dialect/postgres/tx.go index cfb6355dac..32f332d185 100644 --- a/backend/storage/database/dialect/postgres/tx.go +++ b/backend/storage/database/dialect/postgres/tx.go @@ -53,8 +53,7 @@ func (tx *pgxTx) Exec(ctx context.Context, sql string, args ...any) error { // Begin implements [database.Transaction]. // As postgres does not support nested transactions we use savepoints to emulate them. -// TransactionOptions are ignored as savepoints do not support changing isolation levels. -func (tx *pgxTx) Begin(ctx context.Context, _ *database.TransactionOptions) (database.Transaction, error) { +func (tx *pgxTx) Begin(ctx context.Context) (database.Transaction, error) { savepoint, err := tx.Tx.Begin(ctx) if err != nil { return nil, err diff --git a/backend/storage/database/mock/transaction.go b/backend/storage/database/mock/transaction.go index bb32e502fd..f085a685b9 100644 --- a/backend/storage/database/mock/transaction.go +++ b/backend/storage/database/mock/transaction.go @@ -120,7 +120,7 @@ func (tx *Transaction) Exec(ctx context.Context, stmt string, args ...any) error // Begin implements [database.Transaction]. // it is unimplemented -func (tx *Transaction) Begin(ctx context.Context, opts *database.TransactionOptions) (database.Transaction, error) { +func (tx *Transaction) Begin(ctx context.Context) (database.Transaction, error) { return nil, errors.New("nested transactions are not supported") }