Merge pull request #2 from netbirdio/feature/pkg-hook

add hook pkg
This commit is contained in:
Pedro Maia Costa
2025-05-27 09:26:22 +01:00
committed by GitHub
7 changed files with 592 additions and 0 deletions
+10
View File
@@ -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).
+45
View File
@@ -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
}
+29
View File
@@ -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)
}
}
+178
View File
@@ -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()
}
+162
View File
@@ -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)
}
})
}
}
+84
View File
@@ -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()
})
}
+84
View File
@@ -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)
}
})
}
}