From f499844d1dd4eecd8be8354208049e7871d08cdb Mon Sep 17 00:00:00 2001 From: plastikfan Date: Fri, 3 May 2024 12:47:22 +0100 Subject: [PATCH] feat: add prototype tapable code (#4) --- .vscode/settings.json | 2 + cycle/cycle-defs.go | 7 ++ enums/traverse-en.go | 19 ++-- go.mod | 2 +- resources/doc/DESIGN-NOTES.md | 127 +++++++++++++++++++++++++ tapable/legacy-defs.go | 169 ++++++++++++++++++++++++++++++++++ tapable/tapable-defs.go | 96 ++++++++++++++++++- tapable/tapable-suite_test.go | 13 +++ tapable/tapable_test.go | 154 +++++++++++++++++++++++++++++++ 9 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 resources/doc/DESIGN-NOTES.md create mode 100644 tapable/legacy-defs.go create mode 100644 tapable/tapable-suite_test.go create mode 100644 tapable/tapable_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json index cea106d..8b42ba1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,6 +17,7 @@ "dotenv", "dupl", "errcheck", + "Errorf", "exportloopref", "extendio", "fieldalignment", @@ -57,6 +58,7 @@ "structcheck", "stylecheck", "tapable", + "tapcore", "Taskfile", "thelper", "tparallel", diff --git a/cycle/cycle-defs.go b/cycle/cycle-defs.go index e0e1bcc..e7d74d9 100644 --- a/cycle/cycle-defs.go +++ b/cycle/cycle-defs.go @@ -7,3 +7,10 @@ package cycle // eg beforeOptions // afterOptions + +type Role uint32 + +const ( + RoleUndefined Role = iota + RoleDirectoryReader +) diff --git a/enums/traverse-en.go b/enums/traverse-en.go index 77eb417..a71b6ae 100644 --- a/enums/traverse-en.go +++ b/enums/traverse-en.go @@ -12,7 +12,7 @@ const ( SubscribeFiles // invoke callback for files only ) -// Role represents the role of an application entity (like a plugin role) The +// InternalRole represents the role of an application entity (like a plugin role) The // key element of a role is that there should be just a single entity that can take up // the role which is bound to a service. // @@ -22,13 +22,13 @@ const ( // to provide a particular service. // // The mediator knows about Roles and manage registration requests -type Role uint +type InternalRole uint const ( - RoleUndefined Role = iota - RoleLogger // WithLogger - RoleSampler // WithSampler (need a specific sampler interface) - RoleResume // this is not an option; so might not be a valid role + InternalRoleRoleUndefined InternalRole = iota + InternalRoleLogger // WithLogger + InternalRoleSampler // WithSampler (need a specific sampler interface) + InternalRoleResume // this is not an option; so might not be a valid role ) // do we need to distinguish between internal and external entities. It looks @@ -40,3 +40,10 @@ const ( // // --> internal // * resume + +type Role uint32 + +const ( + RoleUndefined Role = iota + RoleDirectoryReader +) diff --git a/go.mod b/go.mod index 6fb4569..6b77576 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/onsi/gomega v1.33.0 github.com/samber/lo v1.39.0 github.com/snivilised/extendio v0.6.1 + golang.org/x/exp v0.0.0-20230321023759-10a507213a29 ) require ( @@ -18,7 +19,6 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.3.0 // indirect github.com/pkg/errors v0.9.1 // indirect - golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/resources/doc/DESIGN-NOTES.md b/resources/doc/DESIGN-NOTES.md new file mode 100644 index 0000000..8aeb1a5 --- /dev/null +++ b/resources/doc/DESIGN-NOTES.md @@ -0,0 +1,127 @@ +# 🪅 Design Notes + + + + + + + + + + + + + + + + + + + +## Tapable + +Given an interface definition: + +```go +type ( + // Activity represents an entity that performs an action that is tapable. The type + // F should be a function signature, defined by the tapable, so F is not really + // any, but it can't be enforced to be a func, so do not try to instantiate with + // a type that is not a func. + Activity[F any] interface { + On(name string, handler F) + } + + // Role is similar to Activity, but it allows the tapable entity to request + // that the client specify which specific behaviour needs to be tapped. This is + // useful when the entity has multi behaviours that clients may wish to tap. + // To enable a client to augment the core action, Tap returns a func of type F which + // represents the core behaviour. If required, the client should store this and then + // invoke it as appropriate when it is called back. + Role[F any, E constraints.Integer] interface { + Tap(name string, role E, fn F) F + } +) +``` + +a component can declare that it wants to expose a hook to enable external customisation of a behaviour. That piece of behaviour is the unit which become tab-able. So we have a Component, it's action and a Client that want to tap into this behaviour, by providing a hook. + +```go +// Widget is some domain abstraction +type Widget struct { + Name string + Amount int +} + +// FuncSimpleWithWidgetAndError is the action func; this function +// can be hooked/is tapable. In fact, this function can either enable core +// functionality to be overridden, decorated with auxiliary behaviour or +// simply be a life-cycle event; ie we have come to a significant point in +// the component's workflow and the some external entity's function needs +// to be invoked. +type FuncSimpleWithWidgetAndError func(name string, amount int) (*Widget, error) + +type ActionHook struct { + name string + action FuncSimpleWithWidgetAndError +} + +// Tap invoked by client to enable registration of the hook +func (h *ActionHook) Tap(_ string, fn FuncSimpleWithWidgetAndError) { + h.action = fn +} + +// Component contains tapable behaviour +type Component struct { + hook ActionHook +} + +func (c *Component) DoWork() { + if _, err := c.hook.action("work", 0); err != nil { + panic(fmt.Errorf("work failed: '%v'", err)) + } +} + +type Client struct { +} + +func (c *Client) WithComponent(component *Component) { + component.hook.Tap("client", func(name string, amount int) (*Widget, error) { + widget := &Widget{ + Name: name, + Amount: amount, + } + return widget, nil + }) +} + +``` + +So we have 3 scenarios to consider: override core behaviour, supplement core behaviour or life cycle. + +### Override + +In extendio, we have the following hook-able actions: + +```go +// TraverseHooks defines the suite of items that can be customised by the client +type TraverseHooks struct { + QueryStatus QueryStatusHookFn + ReadDirectory ReadDirectoryHookFn + FolderSubPath SubPathHookFn + FileSubPath SubPathHookFn + InitFilters FilterInitHookFn + Sort SortEntriesHookFn + Extend ExtendHookFn +} +``` + +These are all core functions that can be overridden by the client. Furthermore, only a single instance is invoked; ie for each one, just a single action is invoked, its neither a notification or broadcast mechanism. + +It may also be pertinent to use the tapable.Role, with the roles being defined as QueryStatus, ReadDirectory, FolderSubPath, ... using a new enum definition. This would serve as an indication to the client that only 1 external entity is able to tap these hook-able actions. + +### Supplement + +This is a piggy-back on top of the Override scenario, where we would like to augment the core functionality. This then makes the hook, a synchronous notification mechanism, which allows the client to control how core functionality is invoked. The client may choose to invoke custom behaviour before/after the core behaviour or completely override it altogether. The client should be able to invoke the core behaviour, which implies that the Tap should return the default func for this action. + +### Life Cycle diff --git a/tapable/legacy-defs.go b/tapable/legacy-defs.go new file mode 100644 index 0000000..e18e7df --- /dev/null +++ b/tapable/legacy-defs.go @@ -0,0 +1,169 @@ +package tapable + +import ( + "fmt" + + "golang.org/x/exp/constraints" +) + +// tapable: enables entities to expose hooks + +type ( + // ActivityL represents an entity that performs an action that is tapable. The type + // F should be a function signature, defined by the tapable, so F is not really + // any, but it can't be enforced to be a func, so do not try to instantiate with + // a type that is not a func. + // To enable a client to augment the core action, Tap returns a func of type F which + // represents the core behaviour. If required, the client should store this and then + // invoke it as appropriate when it is called back. + ActivityL[F any] interface { + Tap(name string, fn F) (F, error) + } + + // RoleL is similar to Activity, but it allows the tapable entity to request + // that the client specify which specific behaviour needs to be tapped. This is + // useful when the entity has multiple behaviours that clients may wish to tap. + RoleL[F any, E constraints.Integer] interface { + Tap(name string, role E, fn F) error + } + + // NotifyL used for life cycle events. There can be multiple subscribers to + // life cycle events + NotifyL[F any, E constraints.Integer] interface { + On(name string, event E, handler F) error + } +) + +// we should have MonoHook (1 client allowed); +// or MultiHook (multiple clients allowed) + +// scratch ... + +// type FuncSimpleWithError func() error + +// type SimpleHookFunc Activity[FuncSimpleWithError] + +// Widget is some domain abstraction +type Widget struct { + Name string + Amount int +} + +// FuncSimpleWithWidgetAndError is the action func; invoke this function +// that can be hooked/tapable. In fact, this function can either enable core +// functionality to be overridden, decorated with auxiliary behaviour or +// simply as a life-cycle event; ie we have come to a significant point in +// the component's workflow and the some external entity's function needs +// to be invoked. +type FuncSimpleWithWidgetAndError func(name string, amount int) (*Widget, error) + +type ActionHook struct { + name string + action FuncSimpleWithWidgetAndError +} + +// Tap invoked by client to enable registration of the hook +func (h *ActionHook) Tap(_ string, fn FuncSimpleWithWidgetAndError) { + h.action = fn +} + +type NotificationHook struct { + name string + action FuncSimpleWithWidgetAndError +} + +// Tap invoked by client to enable registration of the hook +func (h *NotificationHook) On(_ string, fn FuncSimpleWithWidgetAndError) { + h.action = fn +} + +// Component contains tapable behaviour +type Component struct { + hook ActionHook +} + +func (c *Component) DoWork() { + if _, err := c.hook.action("work", 0); err != nil { + panic(fmt.Errorf("work failed: '%v'", err)) + } +} + +type Client struct { +} + +func (c *Client) WithComponent(from *Component) { + from.hook.Tap("client", func(name string, amount int) (*Widget, error) { + widget := &Widget{ + Name: name, + Amount: amount, + } + return widget, nil + }) +} + +// Receiver contains tapable behaviour received as a notification +type Receiver struct { + hook NotificationHook +} + +func (r *Receiver) When(from *Component) { + from.hook.Tap("client", func(name string, amount int) (*Widget, error) { + widget := &Widget{ + Name: name, + Amount: amount, + } + return widget, nil + }) +} + +// The above is still confused. Let's start again we have these scenarios: +// +// --> internal (during bootstrap): ==> actually, this is di, not tap +// * 1 component needs to custom another +// +// --> external (via options): +// * notification of life-cycle events (broadcast) | [On/Notify] (eg, OnBegin/On(enums.cycle.begin)) +// * customise core behaviour, by role (targeted) | [Tap/Role] (eg, ReadDirectory, role=directory-reader) +// * + +// Since we now have finer grain control; ie there are more but smaller packages +// organised as features, each feature can expose its own set of hooks. Having +// said this, I can still only think of nav as needing to expose hooks, but others +// may emerge. +// +// For a component, we have the following situations +// - broadcast, multiple callbacks +// - targeted, single callback +// +// - may expose multiple hooks with different signatures +// the problem this poses is that we can't have a collection of different +// items. This means we need to define a hook container struct that contains the hooks. +// The component aggregates this hook container with a member called hooks. +// For example, in extendio, the options contains a hooks struct TraverseHooks: +// +// type TraverseHooks struct { +// QueryStatus QueryStatusHookFn +// ReadDirectory ReadDirectoryHookFn +// FolderSubPath SubPathHookFn +// FileSubPath SubPathHookFn +// InitFilters FilterInitHookFn +// Sort SortEntriesHookFn +// Extend ExtendHookFn +// } +// +// But we need to ba able to tap these, +// +// if hooks is of type TraverseHooks, in object Component +// component.hooks.ReadDirectory.tap("name", hookFn) +// therefore ReadDirectoryHookFn, can't be the function, there must be a +// level of indirection in-between, +// +// in TraverseHooks, ReadDirectory must be of type ReadDirectoryHook, +// which is an instantiation of a generic type: +// HookFunc[F any], where HookFunc contains the Tap function +// +// type ReadDirectoryHook tapable.HookFunc[ReadDirectoryHookFn] +// +// type TraverseHooks struct { +// ReadDirectory ReadDirectoryHook +// } diff --git a/tapable/tapable-defs.go b/tapable/tapable-defs.go index 0f0e1af..d614e11 100644 --- a/tapable/tapable-defs.go +++ b/tapable/tapable-defs.go @@ -1,3 +1,97 @@ package tapable -// enables entities to expose hooks +import ( + "fmt" + + "golang.org/x/exp/constraints" +) + +type ( + HookName = string + HooksCollection[R constraints.Integer] map[R]HookName + + // Container manages a collection of hooks defined for different roles. Since + // the hook is role specific, there is no way + Container[R constraints.Integer] struct { + hooks HooksCollection[R] + } + + TapContainer[R constraints.Integer] interface { + Tap(name string, role R) error + } + + // Activity represents an entity that performs an action that is tapable. The type + // F should be a function signature, defined by the tapable, so F is not really + // any, but it can't be enforced to be a func, so do not try to instantiate with + // a type that is not a func. + // To enable a client to augment the core action, Tap returns a func of type F which + // represents the core behaviour. If required, the client should store this and then + // invoke it as appropriate when it is called back. + Activity[F any] interface { + Tap(name string, fn F) (F, error) + } + + // Role is similar to Activity, but it allows the tapable entity to request + // that the client specify which specific behaviour needs to be tapped. This is + // useful when the entity has multiple behaviours that clients may wish to tap. + // To enable a client to augment the core action, Tap returns a func of type F which + // represents the core behaviour. If required, the client should store this and then + // invoke it as appropriate when it is called back. + Role[F any, R constraints.Integer] interface { + Tap(name string, role R, fn F) (F, error) + } + + // WithDefault is a helper that binds together a Hook and its associated + // default action. The default is the pure, non-tapable underlying function. + WithDefault[F any, R constraints.Integer] struct { + Name HookName + Role R + Action F + Default F + Container *Container[R] + } + + AlreadyTappedError[R constraints.Integer] struct { + role R + name string + existing string + } +) + +func (e AlreadyTappedError[R]) Error() string { + return fmt.Sprintf("role '%v', already tapped as '%v'", e.role, e.existing) +} + +// NewContainer creates a new instance of a hook container +func NewContainer[R constraints.Integer]() *Container[R] { + return &Container[R]{ + hooks: make(HooksCollection[R]), + } +} + +// Query prevents client from trying to register multiple hooks +// for the same role. +func (c *Container[R]) Query(name string, role R) error { + if existing, found := c.hooks[role]; found { + return &AlreadyTappedError[R]{ + role: role, + name: name, + existing: existing, + } + } + + return nil +} + +// Tap taps into the hook and captures the default action. +func (d *WithDefault[F, R]) Tap(name string, role R, fn F) (F, error) { + if err := d.Container.Query(name, role); err != nil { + return d.Default, err + } + + d.Name = name + d.Role = role + d.Action = fn + + return d.Default, nil +} diff --git a/tapable/tapable-suite_test.go b/tapable/tapable-suite_test.go new file mode 100644 index 0000000..7fa604d --- /dev/null +++ b/tapable/tapable-suite_test.go @@ -0,0 +1,13 @@ +package tapable_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + . "github.com/onsi/gomega" //nolint:revive // ok +) + +func TestTapable(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Tapable Suite") +} diff --git a/tapable/tapable_test.go b/tapable/tapable_test.go new file mode 100644 index 0000000..92c9954 --- /dev/null +++ b/tapable/tapable_test.go @@ -0,0 +1,154 @@ +package tapable_test + +import ( + "io/fs" + + . "github.com/onsi/ginkgo/v2" //nolint:revive // ok + "github.com/onsi/ginkgo/v2/dsl/decorators" + . "github.com/onsi/gomega" //nolint:revive // ok + + "github.com/snivilised/traverse/enums" + "github.com/snivilised/traverse/tapable" +) + +type ( + ReadDirectoryHookFunc func(dirname string) ([]fs.DirEntry, error) + ReadDirectoryHook tapable.ActivityL[ReadDirectoryHookFunc] + + ReaderHost struct { + Name string // init with "default" + Hook ReadDirectoryHookFunc // init with default func + } + + Component struct { + Reader ReaderHost + } + + ExternalReaderClient struct{} +) + +func (c *ExternalReaderClient) Init(from *Component) { + def, err := from.Reader.Tap("external reader client", func(_ string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + }) + + _, _ = def, err +} + +func (r *ReaderHost) Tap(name string, fn ReadDirectoryHookFunc) (ReadDirectoryHookFunc, error) { + previous := r.Hook + r.Hook = fn + r.Name = name + + return previous, nil +} + +func (c *Component) DoWork() { + +} + +var _ = Describe("Legacy Tapable", func() { + Context("foo", func() { + It("should: ", func() { + // !!! this should be using Role + // + component := &Component{ + Reader: ReaderHost{ + Name: "default", + Hook: func(_ string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + }, + }, + } + + external := ExternalReaderClient{} + external.Init(component) + + Expect(1).To(Equal(1)) + }) + }) + + Context("scenarios", func() { + When("client taps component's core action with hook", func() { + + }) + + When("client augments component's core action with hook", func() { + // client need to call default functionality + + }) + + When("client subscribes to component's life-cycle event", func() { + + }) + + Context("Component exposes multiple hooks", func() { + + }) + }) +}) + +type ( + Roles struct { + ReadDirectory tapable.WithDefault[ReadDirectoryHookFunc, enums.Role] + Container *tapable.Container[enums.Role] + } + + Options struct { + Hooks Roles + } +) + +var _ = Describe("Tapable", decorators.Label("use-case"), func() { + Context("foo", func() { + When("bar", func() { + It("should: ", func() { + // This could be exposed to the client as a WithXXX option, + // or the client could perform the Tap manually themselves. + // + container := tapable.NewContainer[enums.Role]() + + // perhaps we make the tapping mechanism internal only and if + // we do it this way, it doesn't matter if the container is passed + // into the hook. We rely on WithXXX options to setup the hook, + // and we do the tap internally on the client's behalf. + // + // WithReadDirectoryHook ==> options.Hooks.ReadDirectory.Tap(...) + // This is with the caveat that there should be a separation of + // the options which can be set directly be the user via With commands + // and another abstraction which contains functional settings (ie + // non persistable items; probably contains the original options + // as a member). + // + // Actually, we can define a 'registry' which represents the options + // used at runtime. IE, the user selects options. Some of the With commands + // may set non persistable items, but these will go straight into + // the registry. The registry will contain options as a member. There + // will be translation from options to registry. Most of the internal + // functionality will be dependent on the registry rather than the + // options. Availability of the registry should become a life cycle event + // during bootstrapping. NB: this is not the registry pattern. + // + options := &Options{ + Hooks: Roles{ + ReadDirectory: tapable.WithDefault[ReadDirectoryHookFunc, enums.Role]{ + Default: func(_ string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + }, + Container: container, + }, + Container: container, + }, + } + + _, _ = options.Hooks.ReadDirectory.Tap( + "client", + enums.RoleDirectoryReader, + func(_ string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + }, + ) + }) + }) + }) +})