Skip to content

feat(examples): add todolist #106

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
3 changes: 2 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ run:
- migrations # NOTE: this is relative to postgres module
- core/internal/user
- postgres/internal/user
- gen/*

linters-settings:
dupl:
Expand Down Expand Up @@ -40,7 +41,7 @@ linters-settings:
govet:
check-shadowing: true
lll:
line-length: 120
line-length: 160
misspell:
locale: US
nolintlint:
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
GO_TEST_FLAGS := -v -race -coverprofile=coverage.out
GO_TEST_FLAGS := -v -race -covermode=atomic -coverpkg=./... -coverprofile=coverage.out
GOLANGCI_LINT_FLAGS ?=

.PHONY: run-linter
Expand Down
62 changes: 62 additions & 0 deletions core/internal/user/get_user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package user

import (
"context"
"errors"
"fmt"
"time"

"github.com/google/uuid"

"github.com/get-eventually/go-eventually/core/aggregate"
"github.com/get-eventually/go-eventually/core/query"
"github.com/get-eventually/go-eventually/core/version"
)

var ErrEmptyID = errors.New("user: empty id provided")

type ViewModel struct {
Version version.Version
ID uuid.UUID
FirstName, LastName string
BirthDate time.Time
Email string
}

func buildViewModel(u *User) ViewModel {
return ViewModel{
Version: u.Version(),
ID: u.id,
FirstName: u.firstName,
LastName: u.lastName,
BirthDate: u.birthDate,
Email: u.email,
}
}

type GetQuery struct {
ID uuid.UUID
}

func (GetQuery) Name() string { return "GetUser" }

type GetQueryHandler struct {
Repository aggregate.Getter[uuid.UUID, *User]
}

func (h GetQueryHandler) Handle(ctx context.Context, q query.Envelope[GetQuery]) (ViewModel, error) {
makeError := func(err error) error {
return fmt.Errorf("user.GetQuery: failed to handle query, %w", err)
}

if q.Message.ID == uuid.Nil {
return ViewModel{}, makeError(ErrEmptyID)
}

user, err := h.Repository.Get(ctx, q.Message.ID)
if err != nil {
return ViewModel{}, makeError(err)
}

return buildViewModel(user), nil
}
3 changes: 3 additions & 0 deletions core/query/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package query contains types and interfaces for implementing Query Handlers,
// useful to request data or information to be exposed through an API.
package query
66 changes: 66 additions & 0 deletions core/query/query.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package query

import (
"context"

"github.com/get-eventually/go-eventually/core/message"
)

// Query is a specific kind of Message that represents the a request for information.
type Query message.Message

// Envelope carries both a Query and some optional Metadata attached to it.
type Envelope[T Query] message.Envelope[T]

// ToGenericEnvelope returns a GenericEnvelope version of the current Envelope instance.
func (cmd Envelope[T]) ToGenericEnvelope() GenericEnvelope {
return GenericEnvelope{
Message: cmd.Message,
Metadata: cmd.Metadata,
}
}

// Handler is the interface that defines a Query Handler,
// a component that receives a specific kind of Query and executes it to return
// the desired output.
type Handler[T Query, R any] interface {
Handle(ctx context.Context, query Envelope[T]) (R, error)
}

// HandlerFunc is a functional type that implements the Handler interface.
// Useful for testing and stateless Handlers.
type HandlerFunc[T Query, R any] func(context.Context, Envelope[T]) (R, error)

// Handle handles the provided Query through the functional Handler.
func (fn HandlerFunc[T, R]) Handle(ctx context.Context, cmd Envelope[T]) (R, error) {
return fn(ctx, cmd)
}

// GenericEnvelope is a Query Envelope that depends solely on the Query interface,
// not a specific generic Query type.
type GenericEnvelope Envelope[Query]

// FromGenericEnvelope attempts to type-cast a GenericEnvelope instance into
// a strongly-typed Query Envelope.
//
// A boolean guard is returned to signal whether the type-casting was successful
// or not.
func FromGenericEnvelope[T Query](cmd GenericEnvelope) (Envelope[T], bool) {
if v, ok := cmd.Message.(T); ok {
return Envelope[T]{
Message: v,
Metadata: cmd.Metadata,
}, true
}

return Envelope[T]{}, false
}

// ToEnvelope is a convenience function that wraps the provided Query type
// into an Envelope, with no metadata attached to it.
func ToEnvelope[T Query](cmd T) Envelope[T] {
return Envelope[T]{
Message: cmd,
Metadata: nil,
}
}
35 changes: 35 additions & 0 deletions core/query/query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package query_test

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/get-eventually/go-eventually/core/query"
)

var (
_ query.Query = queryTest1{}
_ query.Query = queryTest2{}
)

type queryTest1 struct{}

func (queryTest1) Name() string { return "query_test_1" }

type queryTest2 struct{}

func (queryTest2) Name() string { return "query_test_2" }

func TestGenericEnvelope(t *testing.T) {
query1 := query.ToEnvelope(queryTest1{})
genericQuery1 := query1.ToGenericEnvelope()

v1, ok := query.FromGenericEnvelope[queryTest1](genericQuery1)
assert.Equal(t, query1, v1)
assert.True(t, ok)

v2, ok := query.FromGenericEnvelope[queryTest2](genericQuery1)
assert.Zero(t, v2)
assert.False(t, ok)
}
170 changes: 170 additions & 0 deletions core/test/scenario/query_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package scenario

import (
"context"
"testing"

"github.com/stretchr/testify/assert"

"github.com/get-eventually/go-eventually/core/event"
"github.com/get-eventually/go-eventually/core/query"
"github.com/get-eventually/go-eventually/core/test"
"github.com/get-eventually/go-eventually/core/version"
)

// QueryHandlerInit is the entrypoint of the Command Handler scenario API.
//
// A Command Handler scenario can either set the current evaluation context
// by using Given(), or test a "clean-slate" scenario by using When() directly.
type QueryHandlerInit[Q query.Query, R any, T query.Handler[Q, R]] struct{}

// QueryHandler is a scenario type to test the result of Commands
// being handled by a Command Handler.
//
// Command Handlers in Event-sourced systems produce side effects by means
// of Domain Events. This scenario API helps you with testing the Domain Events
// produced by a Command Handler when handling a specific Command.
func QueryHandler[Q query.Query, R any, T query.Handler[Q, R]]() QueryHandlerInit[Q, R, T] {
return QueryHandlerInit[Q, R, T]{}
}

// Given sets the Command Handler scenario preconditions.
//
// Domain Events are used in Event-sourced systems to represent a side effect
// that has taken place in the system. In order to set a given state for the
// system to be in while testing a specific Command evaluation, you should
// specify the Domain Events that have happened thus far.
//
// When you're testing Commands with a clean-slate system, you should either specify
// no Domain Events, or skip directly to When().
func (sc QueryHandlerInit[Q, R, T]) Given(events ...event.Persisted) QueryHandlerGiven[Q, R, T] {
return QueryHandlerGiven[Q, R, T]{
given: events,
}
}

// When provides the Command to evaluate.
func (sc QueryHandlerInit[Q, R, T]) When(cmd query.Envelope[Q]) QueryHandlerWhen[Q, R, T] {
return QueryHandlerWhen[Q, R, T]{
when: cmd,
}
}

// QueryHandlerGiven is the state of the scenario once
// a set of Domain Events have been provided using Given(), to represent
// the state of the system at the time of evaluating a Command.
type QueryHandlerGiven[Q query.Query, R any, T query.Handler[Q, R]] struct {
given []event.Persisted
}

// When provides the Command to evaluate.
func (sc QueryHandlerGiven[Q, R, T]) When(cmd query.Envelope[Q]) QueryHandlerWhen[Q, R, T] {
return QueryHandlerWhen[Q, R, T]{
QueryHandlerGiven: sc,
when: cmd,
}
}

// QueryHandlerWhen is the state of the scenario once the state of the
// system and the Command to evaluate have been provided.
type QueryHandlerWhen[Q query.Query, R any, T query.Handler[Q, R]] struct {
QueryHandlerGiven[Q, R, T]

when query.Envelope[Q]
}

// Then sets a positive expectation on the scenario outcome, to produce
// the Domain Events provided in input.
//
// The list of Domain Events specified should be ordered as the expected
// order of recording by the Command Handler.
func (sc QueryHandlerWhen[Q, R, T]) Then(result R) QueryHandlerThen[Q, R, T] {
return QueryHandlerThen[Q, R, T]{
QueryHandlerWhen: sc,
then: result,
}
}

// ThenError sets a negative expectation on the scenario outcome,
// to produce an error value that is similar to the one provided in input.
//
// Error assertion happens using errors.Is(), so the error returned
// by the Command Handler is unwrapped until the cause error to match
// the provided expectation.
func (sc QueryHandlerWhen[Q, R, T]) ThenError(err error) QueryHandlerThen[Q, R, T] {
return QueryHandlerThen[Q, R, T]{
QueryHandlerWhen: sc,
wantError: true,
thenError: err,
}
}

// ThenFails sets a negative expectation on the scenario outcome,
// to fail the Command execution with no particular assertion on the error returned.
//
// This is useful when the error returned is not important for the Command
// you're trying to test.
func (sc QueryHandlerWhen[Q, R, T]) ThenFails() QueryHandlerThen[Q, R, T] {
return QueryHandlerThen[Q, R, T]{
QueryHandlerWhen: sc,
wantError: true,
}
}

// QueryHandlerThen is the state of the scenario once the preconditions
// and expectations have been fully specified.
type QueryHandlerThen[Q query.Query, R any, T query.Handler[Q, R]] struct {
QueryHandlerWhen[Q, R, T]

then R
thenError error
wantError bool
}

// AssertOn performs the specified expectations of the scenario, using the Command Handler
// instance produced by the provided factory function.
//
// A Command Handler should only use a single Aggregate type, to ensure that the
// side effects happen in a well-defined transactional boundary. If your Command Handler
// needs to modify more than one Aggregate, you might be doing something wrong
// in your domain model.
//
// The type of the Aggregate used to evaluate the Command must be specified,
// so that the Event-sourced Repository instance can be provided to the factory function
// to build the desired Command Handler.
func (sc QueryHandlerThen[Q, R, T]) AssertOn( //nolint:gocritic
t *testing.T,
handlerFactory func(event.Store) T,
) {
ctx := context.Background()
store := test.NewInMemoryEventStore()

for _, event := range sc.given {
_, err := store.Append(ctx, event.StreamID, version.Any, event.Envelope)
if !assert.NoError(t, err) {
return
}
}

handler := handlerFactory(event.FusedStore{
Appender: store,
Streamer: store,
})

result, err := handler.Handle(context.Background(), sc.when)

if !sc.wantError {
assert.NoError(t, err)
assert.Equal(t, sc.then, result)

return
}

if !assert.Error(t, err) {
return
}

if sc.thenError != nil {
assert.ErrorIs(t, err, sc.thenError)
}
}
Loading