add documentation

This commit is contained in:
adlerhurst
2025-05-08 19:01:55 +02:00
parent 47e63ed801
commit c6db6dc4b7
27 changed files with 126 additions and 21 deletions

View File

@@ -1,12 +1,21 @@
// the test used the manly relies on the following patterns:
// - api:
// - some example stubs for the grpc api, it maps the calls and responses to the domain objects
//
// - domain:
// - hexagonal architecture, it defines its dependencies as interfaces and the dependencies must use the objects defined by this package
// - command pattern which implements the changes
// - the invoker decorates the commands by checking for events and tracing
// - the invoker decorates the commands by checking for events, tracing, logging, potentially caching, etc.
// - the database connections are manged in this package
// - the database connections are passed to the repositories
//
// - storage:
// - repository pattern, the repositories are defined as interfaces and the implementations are in the storage package
// - the repositories are used by the domain package to access the database
// - the eventstore to store events. At the beginning it writes to the same events table as the /internal package, afterwards it writes to a different table
//
// - telemetry:
// - logging for standard output
// - tracing for distributed tracing
// - metrics for monitoring
package v3

View File

@@ -7,15 +7,22 @@ import (
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
// Commander is the all it needs to implement the command pattern.
// It is the interface all manipulations need to implement.
// If possible it should also be used for queries. We will find out if this is possible in the future.
type Commander interface {
Execute(ctx context.Context, opts *CommandOpts) (err error)
fmt.Stringer
}
// Invoker is part of the command pattern.
// It is the interface that is used to execute commands.
type Invoker interface {
Invoke(ctx context.Context, command Commander, opts *CommandOpts) error
}
// CommandOpts are passed to each command
// the provide common fields used by commands like the database client.
type CommandOpts struct {
DB database.QueryExecutor
Invoker Invoker
@@ -95,6 +102,8 @@ func DefaultOpts(invoker Invoker) *CommandOpts {
}
}
// commandBatch is a batch of commands.
// It uses the [Invoker] provided by the opts to execute each command.
type commandBatch struct {
Commands []Commander
}

View File

@@ -6,6 +6,10 @@ import (
"github.com/zitadel/zitadel/backend/v3/storage/eventstore"
)
// CreateUserCommand adds a new user including the email verification for humans.
// In the future it might make sense to separate the command into two commands:
// - CreateHumanCommand: creates a new human user
// - CreateMachineCommand: creates a new machine user
type CreateUserCommand struct {
user *User
email *SetEmailCommand
@@ -16,6 +20,7 @@ var (
_ eventer = (*CreateUserCommand)(nil)
)
// opts heavily reduces the complexity for email verification because each type of verification is a simple option which implements the [Commander] interface.
func NewCreateHumanCommand(username string, opts ...CreateHumanOpt) *CreateUserCommand {
cmd := &CreateUserCommand{
user: &User{

View File

@@ -11,6 +11,10 @@ type generateCodeCommand struct {
value *crypto.CryptoValue
}
// I didn't update this repository to the solution proposed please view one of the following interfaces for correct usage:
// - [UserRepository]
// - [InstanceRepository]
// - [OrgRepository]
type CryptoRepository interface {
GetEncryptionConfig(ctx context.Context) (*crypto.GeneratorConfig, error)
}

View File

@@ -11,6 +11,8 @@ import (
"github.com/zitadel/zitadel/internal/crypto"
)
// The variables could also be moved to a struct.
// I just started with the singleton pattern and kept it like this.
var (
pool database.Pool
userCodeAlgorithm crypto.EncryptionAlgorithm

View File

@@ -19,6 +19,7 @@ import (
"github.com/zitadel/zitadel/backend/v3/telemetry/tracing"
)
// These tests give an overview of how to use the domain package.
func TestExample(t *testing.T) {
ctx := context.Background()

View File

@@ -5,6 +5,7 @@ import (
"time"
)
// EmailVerifiedCommand verifies an email address for a user.
type EmailVerifiedCommand struct {
UserID string `json:"userId"`
Email *Email `json:"email"`
@@ -42,6 +43,8 @@ func (cmd *EmailVerifiedCommand) applyOnSetEmail(setEmailCmd *SetEmailCommand) {
setEmailCmd.verification = cmd
}
// SendCodeCommand sends a verification code to the user's email address.
// If the URLTemplate is not set it will use the default of the organization / instance.
type SendCodeCommand struct {
UserID string `json:"userId"`
Email string `json:"email"`
@@ -113,6 +116,8 @@ func (cmd *SendCodeCommand) applyOnSetEmail(setEmailCmd *SetEmailCommand) {
setEmailCmd.verification = cmd
}
// ReturnCodeCommand creates the code and returns it to the caller.
// The caller gets the code by calling the Code field after the command got executed.
type ReturnCodeCommand struct {
UserID string `json:"userId"`
Email string `json:"email"`

View File

@@ -33,6 +33,7 @@ func (i *Instance) Keys(index instanceCacheIndex) (key []string) {
var _ cache.Entry[instanceCacheIndex, string] = (*Instance)(nil)
// instanceColumns define all the columns of the instance table.
type instanceColumns interface {
// IDColumn returns the column for the id field.
IDColumn() database.Column
@@ -46,6 +47,7 @@ type instanceColumns interface {
DeletedAtColumn() database.Column
}
// instanceConditions define all the conditions for the instance table.
type instanceConditions interface {
// IDCondition returns an equal filter on the id field.
IDCondition(instanceID string) database.Condition
@@ -53,16 +55,19 @@ type instanceConditions interface {
NameCondition(op database.TextOperation, name string) database.Condition
}
// instanceChanges define all the changes for the instance table.
type instanceChanges interface {
// SetName sets the name column.
SetName(name string) database.Change
}
// InstanceRepository is the interface for the instance repository.
type InstanceRepository interface {
instanceColumns
instanceConditions
instanceChanges
// Member returns the member repository which is a sub repository of the instance repository.
Member() MemberRepository
Get(ctx context.Context, opts ...database.QueryOption) (*Instance, error)

View File

@@ -7,6 +7,10 @@ import (
"github.com/zitadel/zitadel/backend/v3/storage/eventstore"
)
// Invoke provides a way to execute commands within the domain package.
// It uses a chain of responsibility pattern to handle the command execution.
// The default chain includes logging, tracing, and event publishing.
// If you want to invoke multiple commands in a single transaction, you can use the [commandBatch].
func Invoke(ctx context.Context, cmd Commander) error {
invoker := newEventStoreInvoker(newLoggingInvoker(newTraceInvoker(nil)))
opts := &CommandOpts{
@@ -16,6 +20,8 @@ func Invoke(ctx context.Context, cmd Commander) error {
return invoker.Invoke(ctx, cmd, opts)
}
// eventStoreInvoker checks if the command implements the [eventer] interface.
// If it does, it collects the events and publishes them to the event store.
type eventStoreInvoker struct {
collector *eventCollector
}
@@ -38,6 +44,7 @@ func (i *eventStoreInvoker) Invoke(ctx context.Context, command Commander, opts
return nil
}
// eventCollector collects events from all commands. The [eventStoreInvoker] pushes the collected events after all commands are executed.
type eventCollector struct {
next Invoker
events []*eventstore.Event
@@ -64,6 +71,7 @@ func (i *eventCollector) Invoke(ctx context.Context, command Commander, opts *Co
return command.Execute(ctx, opts)
}
// traceInvoker decorates each command with tracing.
type traceInvoker struct {
next Invoker
}
@@ -87,6 +95,8 @@ func (i *traceInvoker) Invoke(ctx context.Context, command Commander, opts *Comm
return command.Execute(ctx, opts)
}
// loggingInvoker decorates each command with logging.
// It is an example implementation and logs the command name at the beginning and success or failure after the command got executed.
type loggingInvoker struct {
next Invoker
}
@@ -123,6 +133,10 @@ func (i *noopInvoker) Invoke(ctx context.Context, command Commander, opts *Comma
return command.Execute(ctx, opts)
}
// cacheInvoker could be used in the future to do the caching.
// My goal would be to have two interfaces:
// - cacheSetter: which caches an object
// - cacheGetter: which gets an object from the cache, this should also skip the command execution
type cacheInvoker struct {
next Invoker
}

View File

@@ -15,6 +15,7 @@ const (
OrgStateInactive
)
// Org is used by all other packages to represent an organization.
type Org struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -42,6 +43,7 @@ func (o *Org) Keys(index orgCacheIndex) (key []string) {
var _ cache.Entry[orgCacheIndex, string] = (*Org)(nil)
// orgColumns define all the columns of the org table.
type orgColumns interface {
// InstanceIDColumn returns the column for the instance id field.
InstanceIDColumn() database.Column
@@ -59,6 +61,7 @@ type orgColumns interface {
DeletedAtColumn() database.Column
}
// orgConditions define all the conditions for the org table.
type orgConditions interface {
// InstanceIDCondition returns an equal filter on the instance id field.
InstanceIDCondition(instanceID string) database.Condition
@@ -70,6 +73,7 @@ type orgConditions interface {
StateCondition(op database.NumberOperation, state OrgState) database.Condition
}
// orgChanges define all the changes for the org table.
type orgChanges interface {
// SetName sets the name column.
SetName(name string) database.Change
@@ -77,12 +81,14 @@ type orgChanges interface {
SetState(state OrgState) database.Change
}
// OrgRepository is the interface for the org repository.
// It is used to interact with the org table in the database.
type OrgRepository interface {
orgColumns
orgConditions
orgChanges
// Member returns the admin repository.
// Member returns the member repository.
Member() MemberRepository
// Domain returns the domain repository.
Domain() DomainRepository
@@ -99,19 +105,14 @@ type OrgRepository interface {
Update(ctx context.Context, condition database.Condition, changes ...database.Change) error
}
type OrgOperation interface {
MemberRepository
DomainRepository
Update(ctx context.Context, org *Org) error
Delete(ctx context.Context) error
}
// MemberRepository is a sub repository of the org repository and maybe the instance repository.
type MemberRepository interface {
AddMember(ctx context.Context, orgID, userID string, roles []string) error
SetMemberRoles(ctx context.Context, orgID, userID string, roles []string) error
RemoveMember(ctx context.Context, orgID, userID string) error
}
// DomainRepository is a sub repository of the org repository and maybe the instance repository.
type DomainRepository interface {
AddDomain(ctx context.Context, domain string) error
SetDomainVerified(ctx context.Context, domain string) error

View File

@@ -6,6 +6,8 @@ import (
"github.com/zitadel/zitadel/backend/v3/storage/eventstore"
)
// AddOrgCommand adds a new organization.
// I'm unsure if we should add the Admins here or if this should be a separate command.
type AddOrgCommand struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -86,6 +88,8 @@ func (cmd *AddOrgCommand) ensureID() (err error) {
return err
}
// AddMemberCommand adds a new member to an organization.
// I'm not sure if we should make it more generic to also use it for instances.
type AddMemberCommand struct {
orgID string
UserID string `json:"userId"`

View File

@@ -6,6 +6,10 @@ import (
"github.com/zitadel/zitadel/backend/v3/storage/eventstore"
)
// SetEmailCommand sets the email address of a user.
// If allows verification as a sub command.
// The verification command is executed after the email address is set.
// The verification command is executed in the same transaction as the email address update.
type SetEmailCommand struct {
UserID string `json:"userId"`
Email string `json:"email"`

View File

@@ -7,6 +7,7 @@ import (
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
// userColumns define all the columns of the user table.
type userColumns interface {
// InstanceIDColumn returns the column for the instance id field.
InstanceIDColumn() database.Column
@@ -24,6 +25,7 @@ type userColumns interface {
DeletedAtColumn() database.Column
}
// userConditions define all the conditions for the user table.
type userConditions interface {
// InstanceIDCondition returns an equal filter on the instance id field.
InstanceIDCondition(instanceID string) database.Condition
@@ -43,11 +45,13 @@ type userConditions interface {
DeletedAtCondition(op database.NumberOperation, deletedAt time.Time) database.Condition
}
// userChanges define all the changes for the user table.
type userChanges interface {
// SetUsername sets the username column.
SetUsername(username string) database.Change
}
// UserRepository is the interface for the user repository.
type UserRepository interface {
userColumns
userConditions
@@ -66,6 +70,7 @@ type UserRepository interface {
Machine() MachineRepository
}
// humanColumns define all the columns of the human table which inherits the user table.
type humanColumns interface {
userColumns
// FirstNameColumn returns the column for the first name field.
@@ -82,6 +87,7 @@ type humanColumns interface {
PhoneVerifiedAtColumn() database.Column
}
// humanConditions define all the conditions for the human table which inherits the user table.
type humanConditions interface {
userConditions
// FirstNameCondition returns a filter on the first name field.
@@ -103,6 +109,7 @@ type humanConditions interface {
PhoneVerifiedAtCondition(op database.NumberOperation, phoneVerifiedAt time.Time) database.Condition
}
// humanChanges define all the changes for the human table which inherits the user table.
type humanChanges interface {
userChanges
// SetFirstName sets the first name field of the human.
@@ -129,6 +136,7 @@ type humanChanges interface {
SetPhoneVerifiedAt(at time.Time) database.Change
}
// HumanRepository is the interface for the human repository it inherits the user repository.
type HumanRepository interface {
humanColumns
humanConditions
@@ -140,24 +148,28 @@ type HumanRepository interface {
Update(ctx context.Context, condition database.Condition, changes ...database.Change) error
}
// machineColumns define all the columns of the machine table which inherits the user table.
type machineColumns interface {
userColumns
// DescriptionColumn returns the column for the description field.
DescriptionColumn() database.Column
}
// machineConditions define all the conditions for the machine table which inherits the user table.
type machineConditions interface {
userConditions
// DescriptionCondition returns a filter on the description field.
DescriptionCondition(op database.TextOperation, description string) database.Condition
}
// machineChanges define all the changes for the machine table which inherits the user table.
type machineChanges interface {
userChanges
// SetDescription sets the description field of the machine.
SetDescription(description string) database.Change
}
// MachineRepository is the interface for the machine repository it inherits the user repository.
type MachineRepository interface {
// Update updates machine users based on the given condition and changes.
Update(ctx context.Context, condition database.Condition, changes ...database.Change) error
@@ -167,6 +179,7 @@ type MachineRepository interface {
machineChanges
}
// UserTraits is implemented by [Human] and [Machine].
type UserTraits interface {
Type() UserType
}

2
backend/v3/storage/cache/doc.go vendored Normal file
View File

@@ -0,0 +1,2 @@
// this package is copy pasted from the internal/cache package
package cache

View File

@@ -1,5 +1,7 @@
package database
// Change represents a change to a column in a database table.
// Its written in the SET clause of an UPDATE statement.
type Change interface {
Write(builder *StatementBuilder)
}

View File

@@ -12,6 +12,7 @@ func (m Columns) Write(builder *StatementBuilder) {
}
}
// Column represents a column in a database table.
type Column interface {
Write(builder *StatementBuilder)
}
@@ -31,6 +32,8 @@ func (c column) Write(builder *StatementBuilder) {
var _ Column = (*column)(nil)
// ignoreCaseColumn represents two database columns, one for the
// original value and one for the lower case value.
type ignoreCaseColumn interface {
Column
WriteIgnoreCase(builder *StatementBuilder)

View File

@@ -1,5 +1,7 @@
package database
// Condition represents a SQL condition.
// Its written after the WHERE keyword in a SQL statement.
type Condition interface {
Write(builder *StatementBuilder)
}
@@ -22,6 +24,7 @@ func (a *and) Write(builder *StatementBuilder) {
}
}
// And combines multiple conditions with AND.
func And(conditions ...Condition) *and {
return &and{conditions: conditions}
}
@@ -46,6 +49,7 @@ func (o *or) Write(builder *StatementBuilder) {
}
}
// Or combines multiple conditions with OR.
func Or(conditions ...Condition) *or {
return &or{conditions: conditions}
}
@@ -62,6 +66,7 @@ func (i *isNull) Write(builder *StatementBuilder) {
builder.WriteString(" IS NULL")
}
// IsNull creates a condition that checks if a column is NULL.
func IsNull(column Column) *isNull {
return &isNull{column: column}
}
@@ -78,6 +83,7 @@ func (i *isNotNull) Write(builder *StatementBuilder) {
builder.WriteString(" IS NOT NULL")
}
// IsNotNull creates a condition that checks if a column is NOT NULL.
func IsNotNull(column Column) *isNotNull {
return &isNotNull{column: column.(Column)}
}
@@ -86,18 +92,21 @@ var _ Condition = (*isNotNull)(nil)
type valueCondition func(builder *StatementBuilder)
// NewTextCondition creates a condition that compares a text column with a value.
func NewTextCondition[V Text](col Column, op TextOperation, value V) Condition {
return valueCondition(func(builder *StatementBuilder) {
writeTextOperation(builder, col, op, value)
})
}
// NewDateCondition creates a condition that compares a numeric column with a value.
func NewNumberCondition[V Number](col Column, op NumberOperation, value V) Condition {
return valueCondition(func(builder *StatementBuilder) {
writeNumberOperation(builder, col, op, value)
})
}
// NewDateCondition creates a condition that compares a boolean column with a value.
func NewBooleanCondition[V Boolean](col Column, value V) Condition {
return valueCondition(func(builder *StatementBuilder) {
writeBooleanOperation(builder, col, value)

View File

@@ -4,6 +4,7 @@ import (
"context"
)
// Connector abstracts the database driver.
type Connector interface {
Connect(ctx context.Context) (Pool, error)
}

View File

@@ -4,15 +4,7 @@ import (
"context"
)
var (
db *database
)
type database struct {
connector Connector
pool Pool
}
// Pool is a connection pool. e.g. pgxpool
type Pool interface {
Beginner
QueryExecutor
@@ -21,6 +13,7 @@ type Pool interface {
Close(ctx context.Context) error
}
// Client is a single database connection which can be released back to the pool.
type Client interface {
Beginner
QueryExecutor
@@ -28,33 +21,37 @@ type Client interface {
Release(ctx context.Context) error
}
// Querier is a database client that can execute queries and return rows.
type Querier interface {
Query(ctx context.Context, stmt string, args ...any) (Rows, error)
QueryRow(ctx context.Context, stmt string, args ...any) Row
}
// Executor is a database client that can execute statements.
type Executor interface {
Exec(ctx context.Context, stmt string, args ...any) error
}
// QueryExecutor is a database client that can execute queries and statements.
type QueryExecutor interface {
Querier
Executor
}
// Scanner scans a single row of data into the destination.
type Scanner interface {
Scan(dest ...any) error
}
// Row is an abstraction of sql.Row.
type Row interface {
Scanner
}
// Rows is an abstraction of sql.Rows.
type Rows interface {
Row
Next() bool
Close() error
Err() error
}
type Query[T any] func(querier Querier) (result T, err error)

View File

@@ -0,0 +1,2 @@
// pgxpool v5 implementation of the interfaces defined in the database package.
package postgres

View File

@@ -18,6 +18,7 @@ type Text interface {
~string | ~[]byte
}
// TextOperation are operations that can be performed on text values.
type TextOperation uint8
const (
@@ -89,6 +90,7 @@ type Number interface {
constraints.Integer | constraints.Float | constraints.Complex | time.Time | time.Duration
}
// NumberOperation are operations that can be performed on number values.
type NumberOperation uint8
const (
@@ -125,6 +127,7 @@ type Boolean interface {
~bool
}
// BooleanOperation are operations that can be performed on boolean values.
type BooleanOperation uint8
const (

View File

@@ -0,0 +1,5 @@
// Package implements the repositories defined in the domain package.
// The repositories are used by the domain package to access the database.
// the inheritance.sql file is me over-engineering table inheritance.
// I would create a user table which is inherited by human_user and machine_user and the same for objects like idps.
package repository

View File

@@ -2,6 +2,7 @@ package database
import "context"
// Transaction is an SQL transaction.
type Transaction interface {
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
@@ -12,6 +13,7 @@ type Transaction interface {
QueryExecutor
}
// Beginner can start a new transaction.
type Beginner interface {
Begin(ctx context.Context, opts *TransactionOptions) (Transaction, error)
}

View File

@@ -2,6 +2,7 @@ package logging
import "log/slog"
// Logger abstracts [slog.Logger] not sure if thats needed
type Logger struct {
*slog.Logger
}

View File

@@ -0,0 +1,2 @@
// implementation of otel metrics
package metric

View File

@@ -7,6 +7,7 @@ import (
"go.opentelemetry.io/otel/trace/noop"
)
// Tracer is a wrapper around the OpenTelemetry Tracer interface.
type Tracer struct {
trace.Tracer
}