mirror of
https://github.com/netbirdio/management-refactor.git
synced 2026-05-22 17:12:59 -07:00
@@ -0,0 +1,10 @@
|
||||
# Pocketbase Hooks
|
||||
|
||||
This package original authoring is by Gani Georgiev the creator and mantainer of Pocketbase, I've merely ported and adapted it by removing dependencies. You can read more about it and its use on Pocketbase [here](https://pocketbase.io/docs/go-event-hooks/).
|
||||
|
||||
This is a very powerful event/hook system, especially this:
|
||||
|
||||
> All hook handler functions share the same func(e T) error signature and expect the user to call e.Next() if they want to proceed with the execution chain.
|
||||
|
||||
I've included it in our cloud repository and implemented the concept with a sub-set of billing events as a PoC of how we could re-architect Management for a cleaner developer experience and faster development by decoupling server bootstrapping (dependencies), serve (http and gRPC) and actual features (billing, idp, event streaming, etc).
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package hook
|
||||
|
||||
// Resolver defines a common interface for a Hook event (see [Event]).
|
||||
type Resolver interface {
|
||||
// Next triggers the next handler in the hook's chain (if any).
|
||||
Next() error
|
||||
|
||||
// note: kept only for the generic interface; may get removed in the future
|
||||
nextFunc() func() error
|
||||
setNextFunc(f func() error)
|
||||
}
|
||||
|
||||
var _ Resolver = (*Event)(nil)
|
||||
|
||||
// Event implements [Resolver] and it is intended to be used as a base
|
||||
// Hook event that you can embed in your custom typed event structs.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type CustomEvent struct {
|
||||
// hook.Event
|
||||
//
|
||||
// SomeField int
|
||||
// }
|
||||
type Event struct {
|
||||
next func() error
|
||||
}
|
||||
|
||||
// Next calls the next hook handler.
|
||||
func (e *Event) Next() error {
|
||||
if e.next != nil {
|
||||
return e.next()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextFunc returns the function that Next calls.
|
||||
func (e *Event) nextFunc() func() error {
|
||||
return e.next
|
||||
}
|
||||
|
||||
// setNextFunc sets the function that Next calls.
|
||||
func (e *Event) setNextFunc(f func() error) {
|
||||
e.next = f
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package hook
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventNext(t *testing.T) {
|
||||
calls := 0
|
||||
|
||||
e := Event{}
|
||||
|
||||
if e.nextFunc() != nil {
|
||||
t.Fatalf("Expected nextFunc to be nil")
|
||||
}
|
||||
|
||||
e.setNextFunc(func() error {
|
||||
calls++
|
||||
return nil
|
||||
})
|
||||
|
||||
if e.nextFunc() == nil {
|
||||
t.Fatalf("Expected nextFunc to be non-nil")
|
||||
}
|
||||
|
||||
e.Next()
|
||||
e.Next()
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("Expected %d calls, got %d", 2, calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// Handler defines a single Hook handler.
|
||||
// Multiple handlers can share the same id.
|
||||
// If Id is not explicitly set it will be autogenerated by Hook.Add and Hook.AddHandler.
|
||||
type Handler[T Resolver] struct {
|
||||
// Func defines the handler function to execute.
|
||||
//
|
||||
// Note that users need to call e.Next() in order to proceed with
|
||||
// the execution of the hook chain.
|
||||
Func func(T) error
|
||||
|
||||
// Id is the unique identifier of the handler.
|
||||
//
|
||||
// It could be used later to remove the handler from a hook via [Hook.Remove].
|
||||
//
|
||||
// If missing, an autogenerated value will be assigned when adding
|
||||
// the handler to a hook.
|
||||
Id string
|
||||
|
||||
// Priority allows changing the default exec priority of the handler within a hook.
|
||||
//
|
||||
// If 0, the handler will be executed in the same order it was registered.
|
||||
Priority int
|
||||
}
|
||||
|
||||
// Hook defines a generic concurrent safe structure for managing event hooks.
|
||||
//
|
||||
// When using custom event it must embed the base [hook.Event].
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type CustomEvent struct {
|
||||
// hook.Event
|
||||
// SomeField int
|
||||
// }
|
||||
//
|
||||
// h := Hook[*CustomEvent]{}
|
||||
//
|
||||
// h.BindFunc(func(e *CustomEvent) error {
|
||||
// println(e.SomeField)
|
||||
//
|
||||
// return e.Next()
|
||||
// })
|
||||
//
|
||||
// h.Trigger(&CustomEvent{ SomeField: 123 })
|
||||
type Hook[T Resolver] struct {
|
||||
handlers []*Handler[T]
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Bind registers the provided handler to the current hooks queue.
|
||||
//
|
||||
// If handler.Id is empty it is updated with autogenerated value.
|
||||
//
|
||||
// If a handler from the current hook list has Id matching handler.Id
|
||||
// then the old handler is replaced with the new one.
|
||||
func (h *Hook[T]) Bind(handler *Handler[T]) string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
var exists bool
|
||||
|
||||
if handler.Id == "" {
|
||||
handler.Id = generateHookId()
|
||||
|
||||
// ensure that it doesn't exist
|
||||
DUPLICATE_CHECK:
|
||||
for _, existing := range h.handlers {
|
||||
if existing.Id == handler.Id {
|
||||
handler.Id = generateHookId()
|
||||
goto DUPLICATE_CHECK
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// replace existing
|
||||
for i, existing := range h.handlers {
|
||||
if existing.Id == handler.Id {
|
||||
h.handlers[i] = handler
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// append new
|
||||
if !exists {
|
||||
h.handlers = append(h.handlers, handler)
|
||||
}
|
||||
|
||||
// sort handlers by Priority, preserving the original order of equal items
|
||||
sort.SliceStable(h.handlers, func(i, j int) bool {
|
||||
return h.handlers[i].Priority < h.handlers[j].Priority
|
||||
})
|
||||
|
||||
return handler.Id
|
||||
}
|
||||
|
||||
// BindFunc is similar to Bind but registers a new handler from just the provided function.
|
||||
//
|
||||
// The registered handler is added with a default 0 priority and the id will be autogenerated.
|
||||
//
|
||||
// If you want to register a handler with custom priority or id use the [Hook.Bind] method.
|
||||
func (h *Hook[T]) BindFunc(fn func(e T) error) string {
|
||||
return h.Bind(&Handler[T]{Func: fn})
|
||||
}
|
||||
|
||||
// Unbind removes one or many hook handler by their id.
|
||||
func (h *Hook[T]) Unbind(idsToRemove ...string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
for _, id := range idsToRemove {
|
||||
for i := len(h.handlers) - 1; i >= 0; i-- {
|
||||
if h.handlers[i].Id == id {
|
||||
h.handlers = append(h.handlers[:i], h.handlers[i+1:]...)
|
||||
break // for now stop on the first occurrence since we don't allow handlers with duplicated ids
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnbindAll removes all registered handlers.
|
||||
func (h *Hook[T]) UnbindAll() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.handlers = nil
|
||||
}
|
||||
|
||||
// Length returns to total number of registered hook handlers.
|
||||
func (h *Hook[T]) Length() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
return len(h.handlers)
|
||||
}
|
||||
|
||||
// Trigger executes all registered hook handlers one by one
|
||||
// with the specified event as an argument.
|
||||
//
|
||||
// Optionally, this method allows also to register additional one off
|
||||
// handler funcs that will be temporary appended to the handlers queue.
|
||||
//
|
||||
// NB! Each hook handler must call event.Next() in order the hook chain to proceed.
|
||||
func (h *Hook[T]) Trigger(event T, oneOffHandlerFuncs ...func(T) error) error {
|
||||
h.mu.RLock()
|
||||
handlers := make([]func(T) error, 0, len(h.handlers)+len(oneOffHandlerFuncs))
|
||||
for _, handler := range h.handlers {
|
||||
handlers = append(handlers, handler.Func)
|
||||
}
|
||||
handlers = append(handlers, oneOffHandlerFuncs...)
|
||||
h.mu.RUnlock()
|
||||
|
||||
event.setNextFunc(nil) // reset in case the event is being reused
|
||||
|
||||
for i := len(handlers) - 1; i >= 0; i-- {
|
||||
i := i
|
||||
old := event.nextFunc()
|
||||
event.setNextFunc(func() error {
|
||||
event.setNextFunc(old)
|
||||
return handlers[i](event)
|
||||
})
|
||||
}
|
||||
|
||||
return event.Next()
|
||||
}
|
||||
|
||||
func generateHookId() string {
|
||||
return xid.New().String()
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHookAddHandlerAndAdd(t *testing.T) {
|
||||
calls := ""
|
||||
|
||||
h := Hook[*Event]{}
|
||||
|
||||
h.BindFunc(func(e *Event) error { calls += "1"; return e.Next() })
|
||||
h.BindFunc(func(e *Event) error { calls += "2"; return e.Next() })
|
||||
h3Id := h.BindFunc(func(e *Event) error { calls += "3"; return e.Next() })
|
||||
h.Bind(&Handler[*Event]{
|
||||
Id: h3Id, // should replace 3
|
||||
Func: func(e *Event) error { calls += "3'"; return e.Next() },
|
||||
})
|
||||
h.Bind(&Handler[*Event]{
|
||||
Func: func(e *Event) error { calls += "4"; return e.Next() },
|
||||
Priority: -2,
|
||||
})
|
||||
h.Bind(&Handler[*Event]{
|
||||
Func: func(e *Event) error { calls += "5"; return e.Next() },
|
||||
Priority: -1,
|
||||
})
|
||||
h.Bind(&Handler[*Event]{
|
||||
Func: func(e *Event) error { calls += "6"; return e.Next() },
|
||||
})
|
||||
h.Bind(&Handler[*Event]{
|
||||
Func: func(e *Event) error { calls += "7"; e.Next(); return errors.New("test") }, // error shouldn't stop the chain
|
||||
})
|
||||
|
||||
h.Trigger(
|
||||
&Event{},
|
||||
func(e *Event) error { calls += "8"; return e.Next() },
|
||||
func(e *Event) error { calls += "9"; return nil }, // skip next
|
||||
func(e *Event) error { calls += "10"; return e.Next() },
|
||||
)
|
||||
|
||||
if total := len(h.handlers); total != 7 {
|
||||
t.Fatalf("Expected %d handlers, found %d", 7, total)
|
||||
}
|
||||
|
||||
expectedCalls := "45123'6789"
|
||||
|
||||
if calls != expectedCalls {
|
||||
t.Fatalf("Expected calls sequence %q, got %q", expectedCalls, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookLength(t *testing.T) {
|
||||
h := Hook[*Event]{}
|
||||
|
||||
if l := h.Length(); l != 0 {
|
||||
t.Fatalf("Expected 0 hook handlers, got %d", l)
|
||||
}
|
||||
|
||||
h.BindFunc(func(e *Event) error { return e.Next() })
|
||||
h.BindFunc(func(e *Event) error { return e.Next() })
|
||||
|
||||
if l := h.Length(); l != 2 {
|
||||
t.Fatalf("Expected 2 hook handlers, got %d", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookUnbind(t *testing.T) {
|
||||
h := Hook[*Event]{}
|
||||
|
||||
calls := ""
|
||||
|
||||
id0 := h.BindFunc(func(e *Event) error { calls += "0"; return e.Next() })
|
||||
id1 := h.BindFunc(func(e *Event) error { calls += "1"; return e.Next() })
|
||||
h.BindFunc(func(e *Event) error { calls += "2"; return e.Next() })
|
||||
h.Bind(&Handler[*Event]{
|
||||
Func: func(e *Event) error { calls += "3"; return e.Next() },
|
||||
})
|
||||
|
||||
h.Unbind("missing") // should do nothing and not panic
|
||||
|
||||
if total := len(h.handlers); total != 4 {
|
||||
t.Fatalf("Expected %d handlers, got %d", 4, total)
|
||||
}
|
||||
|
||||
h.Unbind(id1, id0)
|
||||
|
||||
if total := len(h.handlers); total != 2 {
|
||||
t.Fatalf("Expected %d handlers, got %d", 2, total)
|
||||
}
|
||||
|
||||
err := h.Trigger(&Event{}, func(e *Event) error { calls += "4"; return e.Next() })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedCalls := "234"
|
||||
|
||||
if calls != expectedCalls {
|
||||
t.Fatalf("Expected calls sequence %q, got %q", expectedCalls, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookUnbindAll(t *testing.T) {
|
||||
h := Hook[*Event]{}
|
||||
|
||||
h.UnbindAll() // should do nothing and not panic
|
||||
|
||||
h.BindFunc(func(e *Event) error { return nil })
|
||||
h.BindFunc(func(e *Event) error { return nil })
|
||||
|
||||
if total := len(h.handlers); total != 2 {
|
||||
t.Fatalf("Expected %d handlers before UnbindAll, found %d", 2, total)
|
||||
}
|
||||
|
||||
h.UnbindAll()
|
||||
|
||||
if total := len(h.handlers); total != 0 {
|
||||
t.Fatalf("Expected no handlers after UnbindAll, found %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookTriggerErrorPropagation(t *testing.T) {
|
||||
err := errors.New("test")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
handlers []func(*Event) error
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
"without error",
|
||||
[]func(*Event) error{
|
||||
func(e *Event) error { return e.Next() },
|
||||
func(e *Event) error { return e.Next() },
|
||||
},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"with error",
|
||||
[]func(*Event) error{
|
||||
func(e *Event) error { return e.Next() },
|
||||
func(e *Event) error { e.Next(); return err },
|
||||
func(e *Event) error { return e.Next() },
|
||||
},
|
||||
err,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
h := Hook[*Event]{}
|
||||
for _, handler := range s.handlers {
|
||||
h.BindFunc(handler)
|
||||
}
|
||||
result := h.Trigger(&Event{})
|
||||
if result != s.expectedError {
|
||||
t.Fatalf("Expected %v, got %v", s.expectedError, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Tagger defines an interface for event data structs that support tags/groups/categories/etc.
|
||||
// Usually used together with TaggedHook.
|
||||
type Tagger interface {
|
||||
Resolver
|
||||
|
||||
Tags() []string
|
||||
}
|
||||
|
||||
// wrapped local Hook embedded struct to limit the public API surface.
|
||||
type mainHook[T Tagger] struct {
|
||||
*Hook[T]
|
||||
}
|
||||
|
||||
// NewTaggedHook creates a new TaggedHook with the provided main hook and optional tags.
|
||||
func NewTaggedHook[T Tagger](hook *Hook[T], tags ...string) *TaggedHook[T] {
|
||||
return &TaggedHook[T]{
|
||||
mainHook[T]{hook},
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
// TaggedHook defines a proxy hook which register handlers that are triggered only
|
||||
// if the TaggedHook.tags are empty or includes at least one of the event data tag(s).
|
||||
type TaggedHook[T Tagger] struct {
|
||||
mainHook[T]
|
||||
|
||||
tags []string
|
||||
}
|
||||
|
||||
// CanTriggerOn checks if the current TaggedHook can be triggered with
|
||||
// the provided event data tags.
|
||||
//
|
||||
// It returns always true if the hook doens't have any tags.
|
||||
func (h *TaggedHook[T]) CanTriggerOn(tagsToCheck []string) bool {
|
||||
if len(h.tags) == 0 {
|
||||
return true // match all
|
||||
}
|
||||
|
||||
for _, t := range tagsToCheck {
|
||||
if slices.Contains(h.tags, t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Bind registers the provided handler to the current hooks queue.
|
||||
//
|
||||
// It is similar to [Hook.Bind] with the difference that the handler
|
||||
// function is invoked only if the event data tags satisfy h.CanTriggerOn.
|
||||
func (h *TaggedHook[T]) Bind(handler *Handler[T]) string {
|
||||
fn := handler.Func
|
||||
|
||||
handler.Func = func(e T) error {
|
||||
if h.CanTriggerOn(e.Tags()) {
|
||||
return fn(e)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
return h.mainHook.Bind(handler)
|
||||
}
|
||||
|
||||
// BindFunc registers a new handler with the specified function.
|
||||
//
|
||||
// It is similar to [Hook.Bind] with the difference that the handler
|
||||
// function is invoked only if the event data tags satisfy h.CanTriggerOn.
|
||||
func (h *TaggedHook[T]) BindFunc(fn func(e T) error) string {
|
||||
return h.mainHook.BindFunc(func(e T) error {
|
||||
if h.CanTriggerOn(e.Tags()) {
|
||||
return fn(e)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mockTagsEvent struct {
|
||||
Event
|
||||
tags []string
|
||||
}
|
||||
|
||||
func (m mockTagsEvent) Tags() []string {
|
||||
return m.tags
|
||||
}
|
||||
|
||||
func TestTaggedHook(t *testing.T) {
|
||||
calls := ""
|
||||
|
||||
base := &Hook[*mockTagsEvent]{}
|
||||
base.BindFunc(func(e *mockTagsEvent) error { calls += "f0"; return e.Next() })
|
||||
|
||||
hA := NewTaggedHook(base)
|
||||
hA.BindFunc(func(e *mockTagsEvent) error { calls += "a1"; return e.Next() })
|
||||
hA.Bind(&Handler[*mockTagsEvent]{
|
||||
Func: func(e *mockTagsEvent) error { calls += "a2"; return e.Next() },
|
||||
Priority: -1,
|
||||
})
|
||||
|
||||
hB := NewTaggedHook(base, "b1", "b2")
|
||||
hB.BindFunc(func(e *mockTagsEvent) error { calls += "b1"; return e.Next() })
|
||||
hB.Bind(&Handler[*mockTagsEvent]{
|
||||
Func: func(e *mockTagsEvent) error { calls += "b2"; return e.Next() },
|
||||
Priority: -2,
|
||||
})
|
||||
|
||||
hC := NewTaggedHook(base, "c1", "c2")
|
||||
hC.BindFunc(func(e *mockTagsEvent) error { calls += "c1"; return e.Next() })
|
||||
hC.Bind(&Handler[*mockTagsEvent]{
|
||||
Func: func(e *mockTagsEvent) error { calls += "c2"; return e.Next() },
|
||||
Priority: -3,
|
||||
})
|
||||
|
||||
scenarios := []struct {
|
||||
event *mockTagsEvent
|
||||
expectedCalls string
|
||||
}{
|
||||
{
|
||||
&mockTagsEvent{},
|
||||
"a2f0a1",
|
||||
},
|
||||
{
|
||||
&mockTagsEvent{tags: []string{"missing"}},
|
||||
"a2f0a1",
|
||||
},
|
||||
{
|
||||
&mockTagsEvent{tags: []string{"b2"}},
|
||||
"b2a2f0a1b1",
|
||||
},
|
||||
{
|
||||
&mockTagsEvent{tags: []string{"c1"}},
|
||||
"c2a2f0a1c1",
|
||||
},
|
||||
{
|
||||
&mockTagsEvent{tags: []string{"b1", "c2"}},
|
||||
"c2b2a2f0a1b1c1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(strings.Join(s.event.tags, "_"), func(t *testing.T) {
|
||||
calls = "" // reset
|
||||
|
||||
err := base.Trigger(s.event)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected trigger error: %v", err)
|
||||
}
|
||||
|
||||
if calls != s.expectedCalls {
|
||||
t.Fatalf("Expected calls sequence %q, got %q", s.expectedCalls, calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user