mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
feat(core): add typed eventbus and domain extension points
This commit is contained in:
+75
-9
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Context is the central micro-kernel service bus and runtime lifecycle container.
|
// Context is the central micro-kernel service bus and runtime lifecycle container.
|
||||||
@@ -17,6 +19,13 @@ type Context struct {
|
|||||||
parent *Context
|
parent *Context
|
||||||
container *Container
|
container *Container
|
||||||
|
|
||||||
|
events *EventBus
|
||||||
|
router extpoints.RouterExtension
|
||||||
|
migrations extpoints.MigrationExtension
|
||||||
|
tasks extpoints.TaskExtension
|
||||||
|
schedules extpoints.ScheduleExtension
|
||||||
|
settings extpoints.SettingExtension
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
children []*Context
|
children []*Context
|
||||||
disposers []Disposer
|
disposers []Disposer
|
||||||
@@ -34,10 +43,16 @@ func NewContext(base context.Context) *Context {
|
|||||||
ctx, cancel := context.WithCancel(base)
|
ctx, cancel := context.WithCancel(base)
|
||||||
|
|
||||||
return &Context{
|
return &Context{
|
||||||
goCtx: ctx,
|
goCtx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
container: NewContainer(nil),
|
container: NewContainer(nil),
|
||||||
values: make(map[any]any),
|
events: NewEventBus(),
|
||||||
|
router: extpoints.NewRouterRegistry(),
|
||||||
|
migrations: extpoints.NewMigrationRegistry(),
|
||||||
|
tasks: extpoints.NewTaskRegistry(),
|
||||||
|
schedules: extpoints.NewScheduleRegistry(),
|
||||||
|
settings: extpoints.NewSettingRegistry(),
|
||||||
|
values: make(map[any]any),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +142,17 @@ func (c *Context) ForkWithContext(base context.Context) *Context {
|
|||||||
ctx, cancel := context.WithCancel(base)
|
ctx, cancel := context.WithCancel(base)
|
||||||
|
|
||||||
child := &Context{
|
child := &Context{
|
||||||
goCtx: ctx,
|
goCtx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
parent: c,
|
parent: c,
|
||||||
container: NewContainer(c.container),
|
container: NewContainer(c.container),
|
||||||
values: make(map[any]any),
|
events: c.Events(),
|
||||||
|
router: c.Router(),
|
||||||
|
migrations: c.Migrations(),
|
||||||
|
tasks: c.Tasks(),
|
||||||
|
schedules: c.Schedules(),
|
||||||
|
settings: c.Settings(),
|
||||||
|
values: make(map[any]any),
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -141,6 +162,51 @@ func (c *Context) ForkWithContext(base context.Context) *Context {
|
|||||||
return child
|
return child
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Events returns the domain EventBus associated with this Context hierarchy.
|
||||||
|
func (c *Context) Events() *EventBus {
|
||||||
|
return c.events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router returns the RouterExtension registry.
|
||||||
|
func (c *Context) Router() extpoints.RouterExtension {
|
||||||
|
return c.router
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrations returns the MigrationExtension registry.
|
||||||
|
func (c *Context) Migrations() extpoints.MigrationExtension {
|
||||||
|
return c.migrations
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks returns the TaskExtension registry.
|
||||||
|
func (c *Context) Tasks() extpoints.TaskExtension {
|
||||||
|
return c.tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task is an alias for Tasks().
|
||||||
|
func (c *Context) Task() extpoints.TaskExtension {
|
||||||
|
return c.tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedules returns the ScheduleExtension registry.
|
||||||
|
func (c *Context) Schedules() extpoints.ScheduleExtension {
|
||||||
|
return c.schedules
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule is an alias for Schedules().
|
||||||
|
func (c *Context) Schedule() extpoints.ScheduleExtension {
|
||||||
|
return c.schedules
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings returns the SettingExtension registry.
|
||||||
|
func (c *Context) Settings() extpoints.SettingExtension {
|
||||||
|
return c.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting is an alias for Settings().
|
||||||
|
func (c *Context) Setting() extpoints.SettingExtension {
|
||||||
|
return c.settings
|
||||||
|
}
|
||||||
|
|
||||||
// OnDispose registers a cleanup callback function to be executed when this Context is disposed.
|
// OnDispose registers a cleanup callback function to be executed when this Context is disposed.
|
||||||
// It accepts func() error, func(), or Disposer.
|
// It accepts func() error, func(), or Disposer.
|
||||||
func (c *Context) OnDispose(fn any) {
|
func (c *Context) OnDispose(fn any) {
|
||||||
|
|||||||
@@ -473,3 +473,27 @@ func TestConcurrentAccess(t *testing.T) {
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContextExtensionPointsAccessors(t *testing.T) {
|
||||||
|
ctx := core.NewContext(nil)
|
||||||
|
assert.NotNil(t, ctx.Events())
|
||||||
|
assert.NotNil(t, ctx.Router())
|
||||||
|
assert.NotNil(t, ctx.Migrations())
|
||||||
|
assert.NotNil(t, ctx.Tasks())
|
||||||
|
assert.NotNil(t, ctx.Task())
|
||||||
|
assert.NotNil(t, ctx.Schedules())
|
||||||
|
assert.NotNil(t, ctx.Schedule())
|
||||||
|
assert.NotNil(t, ctx.Settings())
|
||||||
|
assert.NotNil(t, ctx.Setting())
|
||||||
|
|
||||||
|
child := ctx.Fork()
|
||||||
|
assert.Equal(t, ctx.Events(), child.Events())
|
||||||
|
assert.Equal(t, ctx.Router(), child.Router())
|
||||||
|
assert.Equal(t, ctx.Migrations(), child.Migrations())
|
||||||
|
assert.Equal(t, ctx.Tasks(), child.Tasks())
|
||||||
|
assert.Equal(t, ctx.Task(), child.Task())
|
||||||
|
assert.Equal(t, ctx.Schedules(), child.Schedules())
|
||||||
|
assert.Equal(t, ctx.Schedule(), child.Schedule())
|
||||||
|
assert.Equal(t, ctx.Settings(), child.Settings())
|
||||||
|
assert.Equal(t, ctx.Setting(), child.Setting())
|
||||||
|
}
|
||||||
|
|||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ctxInterfaceType = reflect.TypeFor[context.Context]()
|
||||||
|
var errInterfaceType = reflect.TypeFor[error]()
|
||||||
|
|
||||||
|
type eventListener struct {
|
||||||
|
id uint64
|
||||||
|
fnVal reflect.Value
|
||||||
|
numIn int
|
||||||
|
hasCtx bool
|
||||||
|
hasPayload bool
|
||||||
|
argType reflect.Type
|
||||||
|
returnsErr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventBus is a thread-safe, strongly-typed in-process domain event bus.
|
||||||
|
type EventBus struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
nextID atomic.Uint64
|
||||||
|
handlers map[string][]eventListener
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEventBus creates a new EventBus instance.
|
||||||
|
func NewEventBus() *EventBus {
|
||||||
|
return &EventBus{
|
||||||
|
handlers: make(map[string][]eventListener),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On registers an event handler for the given topic.
|
||||||
|
//
|
||||||
|
// Supported handler signatures:
|
||||||
|
// - func(ctx context.Context, event T) error
|
||||||
|
// - func(ctx context.Context, event T)
|
||||||
|
// - func(event T) error
|
||||||
|
// - func(event T)
|
||||||
|
// - func(ctx context.Context) error
|
||||||
|
// - func(ctx context.Context)
|
||||||
|
// - func() error
|
||||||
|
// - func()
|
||||||
|
//
|
||||||
|
// Returns a Disposer function that unregisters the handler when called.
|
||||||
|
func (b *EventBus) On(topic string, handler any) Disposer {
|
||||||
|
if handler == nil {
|
||||||
|
panic("core/events: handler cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
fnVal := reflect.ValueOf(handler)
|
||||||
|
fnType := fnVal.Type()
|
||||||
|
|
||||||
|
if fnType.Kind() != reflect.Func {
|
||||||
|
panic(fmt.Sprintf("core/events: expected func, got %s", fnType.Kind()))
|
||||||
|
}
|
||||||
|
|
||||||
|
numIn := fnType.NumIn()
|
||||||
|
if numIn > 2 {
|
||||||
|
panic(fmt.Sprintf("core/events: handler has %d parameters, maximum 2 supported (ctx, event)", numIn))
|
||||||
|
}
|
||||||
|
|
||||||
|
numOut := fnType.NumOut()
|
||||||
|
if numOut > 1 {
|
||||||
|
panic(fmt.Sprintf("core/events: handler has %d return values, maximum 1 supported (error)", numOut))
|
||||||
|
}
|
||||||
|
|
||||||
|
returnsErr := false
|
||||||
|
if numOut == 1 {
|
||||||
|
outType := fnType.Out(0)
|
||||||
|
if !outType.Implements(errInterfaceType) {
|
||||||
|
panic(fmt.Sprintf("core/events: handler return type must be error, got %v", outType))
|
||||||
|
}
|
||||||
|
returnsErr = true
|
||||||
|
}
|
||||||
|
|
||||||
|
listener := eventListener{
|
||||||
|
id: b.nextID.Add(1),
|
||||||
|
fnVal: fnVal,
|
||||||
|
numIn: numIn,
|
||||||
|
returnsErr: returnsErr,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch numIn {
|
||||||
|
case 0:
|
||||||
|
// func() or func() error
|
||||||
|
case 1:
|
||||||
|
in0 := fnType.In(0)
|
||||||
|
if in0.Implements(ctxInterfaceType) {
|
||||||
|
listener.hasCtx = true
|
||||||
|
} else {
|
||||||
|
listener.hasPayload = true
|
||||||
|
listener.argType = in0
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
in0 := fnType.In(0)
|
||||||
|
if !in0.Implements(ctxInterfaceType) {
|
||||||
|
panic(fmt.Sprintf("core/events: first parameter must implement context.Context, got %v", in0))
|
||||||
|
}
|
||||||
|
listener.hasCtx = true
|
||||||
|
listener.hasPayload = true
|
||||||
|
listener.argType = fnType.In(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.handlers[topic] = append(b.handlers[topic], listener)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
listenerID := listener.id
|
||||||
|
var disposed atomic.Bool
|
||||||
|
|
||||||
|
return func() error {
|
||||||
|
if disposed.Swap(true) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
list := b.handlers[topic]
|
||||||
|
for i, l := range list {
|
||||||
|
if l.id == listenerID {
|
||||||
|
b.handlers[topic] = append(list[:i], list[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(b.handlers[topic]) == 0 {
|
||||||
|
delete(b.handlers, topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers a strongly-typed generic event listener on the given EventBus.
|
||||||
|
func Subscribe[T any](bus *EventBus, topic string, handler func(ctx context.Context, event T) error) Disposer {
|
||||||
|
if bus == nil {
|
||||||
|
panic("core/events: nil EventBus provided to Subscribe")
|
||||||
|
}
|
||||||
|
return bus.On(topic, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit publishes an event to all subscribers of the specified topic.
|
||||||
|
// Handlers are executed synchronously. If any handler panics or returns an error,
|
||||||
|
// the error is collected and returned via errors.Join.
|
||||||
|
func (b *EventBus) Emit(ctx context.Context, topic string, payload any) error {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.RLock()
|
||||||
|
rawListeners := b.handlers[topic]
|
||||||
|
if len(rawListeners) == 0 {
|
||||||
|
b.mu.RUnlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners := make([]eventListener, len(rawListeners))
|
||||||
|
copy(listeners, rawListeners)
|
||||||
|
b.mu.RUnlock()
|
||||||
|
|
||||||
|
var payloadVal reflect.Value
|
||||||
|
if payload != nil {
|
||||||
|
payloadVal = reflect.ValueOf(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for _, l := range listeners {
|
||||||
|
args := b.buildArgs(ctx, l, payloadVal)
|
||||||
|
|
||||||
|
err := func() (resErr error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
resErr = fmt.Errorf("core/events: panic in handler for topic %q: %v", topic, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
results := l.fnVal.Call(args)
|
||||||
|
if l.returnsErr && len(results) > 0 && !results[0].IsNil() {
|
||||||
|
resErr = results[0].Interface().(error)
|
||||||
|
}
|
||||||
|
return resErr
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) buildArgs(ctx context.Context, l eventListener, payloadVal reflect.Value) []reflect.Value {
|
||||||
|
if l.numIn == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := make([]reflect.Value, 0, l.numIn)
|
||||||
|
if l.hasCtx {
|
||||||
|
args = append(args, reflect.ValueOf(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
if l.hasPayload {
|
||||||
|
arg := b.convertPayload(payloadVal, l.argType)
|
||||||
|
args = append(args, arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) convertPayload(payloadVal reflect.Value, targetType reflect.Type) reflect.Value {
|
||||||
|
if !payloadVal.IsValid() {
|
||||||
|
return reflect.Zero(targetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
valType := payloadVal.Type()
|
||||||
|
|
||||||
|
// 1. Direct assignable
|
||||||
|
if valType.AssignableTo(targetType) {
|
||||||
|
return payloadVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Direct convertible
|
||||||
|
if valType.ConvertibleTo(targetType) {
|
||||||
|
return payloadVal.Convert(targetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Payload is pointer *T, target expects T
|
||||||
|
if valType.Kind() == reflect.Pointer && valType.Elem().AssignableTo(targetType) {
|
||||||
|
if !payloadVal.IsNil() {
|
||||||
|
return payloadVal.Elem()
|
||||||
|
}
|
||||||
|
return reflect.Zero(targetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Payload is value T, target expects *T
|
||||||
|
if targetType.Kind() == reflect.Pointer && valType.AssignableTo(targetType.Elem()) {
|
||||||
|
ptr := reflect.New(valType)
|
||||||
|
ptr.Elem().Set(payloadVal)
|
||||||
|
return ptr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to zero value of targetType
|
||||||
|
return reflect.Zero(targetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listeners returns the number of active listeners for a topic.
|
||||||
|
func (b *EventBus) Listeners(topic string) int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
return len(b.handlers[topic])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topics returns all topics that have registered listeners.
|
||||||
|
func (b *EventBus) Topics() []string {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
topics := make([]string, 0, len(b.handlers))
|
||||||
|
for t := range b.handlers {
|
||||||
|
topics = append(topics, t)
|
||||||
|
}
|
||||||
|
return topics
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRegisteredEvent struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderCreatedEvent struct {
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBusPublishSubscribe(t *testing.T) {
|
||||||
|
bus := core.NewEventBus()
|
||||||
|
var receivedID string
|
||||||
|
|
||||||
|
disposer := bus.On("user:registered", func(ctx context.Context, e UserRegisteredEvent) error {
|
||||||
|
receivedID = e.UserID
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NotNil(t, disposer)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"user:registered"}, bus.Topics())
|
||||||
|
|
||||||
|
err := bus.Emit(context.Background(), "user:registered", UserRegisteredEvent{UserID: "u_999", Username: "alice"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "u_999", receivedID)
|
||||||
|
|
||||||
|
// Emit to empty topic returns nil error
|
||||||
|
err = bus.Emit(nil, "empty:topic", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBusGenericSubscribe(t *testing.T) {
|
||||||
|
bus := core.NewEventBus()
|
||||||
|
var receivedOrder string
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
core.Subscribe[OrderCreatedEvent](nil, "order:created", func(ctx context.Context, e OrderCreatedEvent) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
disposer := core.Subscribe(bus, "order:created", func(ctx context.Context, e OrderCreatedEvent) error {
|
||||||
|
receivedOrder = e.OrderID
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NotNil(t, disposer)
|
||||||
|
|
||||||
|
err := bus.Emit(context.Background(), "order:created", OrderCreatedEvent{OrderID: "ord_123", Amount: 99.5})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "ord_123", receivedOrder)
|
||||||
|
|
||||||
|
// Test unsubscribe via disposer
|
||||||
|
err = disposer()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
receivedOrder = ""
|
||||||
|
err = bus.Emit(context.Background(), "order:created", OrderCreatedEvent{OrderID: "ord_456", Amount: 100})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, receivedOrder, "handler should not be called after disposal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBusHandlerSignatures(t *testing.T) {
|
||||||
|
bus := core.NewEventBus()
|
||||||
|
|
||||||
|
var (
|
||||||
|
calledWithCtxPayloadErr atomic.Bool
|
||||||
|
calledWithCtxPayload atomic.Bool
|
||||||
|
calledWithPayloadErr atomic.Bool
|
||||||
|
calledWithPayload atomic.Bool
|
||||||
|
calledWithCtxErr atomic.Bool
|
||||||
|
calledWithCtx atomic.Bool
|
||||||
|
calledWithNoArgsErr atomic.Bool
|
||||||
|
calledWithNoArgs atomic.Bool
|
||||||
|
)
|
||||||
|
|
||||||
|
bus.On("test:sig", func(ctx context.Context, e UserRegisteredEvent) error {
|
||||||
|
calledWithCtxPayloadErr.Store(true)
|
||||||
|
assert.Equal(t, "u_1", e.UserID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.On("test:sig", func(ctx context.Context, e UserRegisteredEvent) {
|
||||||
|
calledWithCtxPayload.Store(true)
|
||||||
|
assert.Equal(t, "u_1", e.UserID)
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.On("test:sig", func(e UserRegisteredEvent) error {
|
||||||
|
calledWithPayloadErr.Store(true)
|
||||||
|
assert.Equal(t, "u_1", e.UserID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.On("test:sig", func(e UserRegisteredEvent) {
|
||||||
|
calledWithPayload.Store(true)
|
||||||
|
assert.Equal(t, "u_1", e.UserID)
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.On("test:sig", func(ctx context.Context) error {
|
||||||
|
calledWithCtxErr.Store(true)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.On("test:sig", func(ctx context.Context) {
|
||||||
|
calledWithCtx.Store(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.On("test:sig", func() error {
|
||||||
|
calledWithNoArgsErr.Store(true)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.On("test:sig", func() {
|
||||||
|
calledWithNoArgs.Store(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
err := bus.Emit(context.Background(), "test:sig", UserRegisteredEvent{UserID: "u_1", Username: "test"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, calledWithCtxPayloadErr.Load())
|
||||||
|
assert.True(t, calledWithCtxPayload.Load())
|
||||||
|
assert.True(t, calledWithPayloadErr.Load())
|
||||||
|
assert.True(t, calledWithPayload.Load())
|
||||||
|
assert.True(t, calledWithCtxErr.Load())
|
||||||
|
assert.True(t, calledWithCtx.Load())
|
||||||
|
assert.True(t, calledWithNoArgsErr.Load())
|
||||||
|
assert.True(t, calledWithNoArgs.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBusPointerAndValueConversion(t *testing.T) {
|
||||||
|
bus := core.NewEventBus()
|
||||||
|
|
||||||
|
var (
|
||||||
|
receivedFromValueToPtr atomic.Bool
|
||||||
|
receivedFromPtrToValue atomic.Bool
|
||||||
|
receivedFromNilPtr atomic.Bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler expects pointer, payload emitted as value
|
||||||
|
bus.On("test:ptr", func(ctx context.Context, e *UserRegisteredEvent) error {
|
||||||
|
if e != nil && e.UserID == "u_ptr" {
|
||||||
|
receivedFromValueToPtr.Store(true)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err := bus.Emit(context.Background(), "test:ptr", UserRegisteredEvent{UserID: "u_ptr"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, receivedFromValueToPtr.Load())
|
||||||
|
|
||||||
|
// Handler expects value, payload emitted as pointer
|
||||||
|
bus.On("test:val", func(ctx context.Context, e UserRegisteredEvent) error {
|
||||||
|
if e.UserID == "u_val" {
|
||||||
|
receivedFromPtrToValue.Store(true)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err = bus.Emit(context.Background(), "test:val", &UserRegisteredEvent{UserID: "u_val"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, receivedFromPtrToValue.Load())
|
||||||
|
|
||||||
|
// Handler expects value, payload is nil pointer
|
||||||
|
var nilEvent *UserRegisteredEvent
|
||||||
|
bus.On("test:nil_ptr", func(ctx context.Context, e UserRegisteredEvent) error {
|
||||||
|
assert.Equal(t, "", e.UserID)
|
||||||
|
receivedFromNilPtr.Store(true)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
err = bus.Emit(context.Background(), "test:nil_ptr", nilEvent)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, receivedFromNilPtr.Load())
|
||||||
|
|
||||||
|
// Convertible type test (int to int64)
|
||||||
|
var receivedConvert int64
|
||||||
|
bus.On("test:conv", func(e int64) {
|
||||||
|
receivedConvert = e
|
||||||
|
})
|
||||||
|
err = bus.Emit(context.Background(), "test:conv", int(42))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(42), receivedConvert)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBusErrorCollectionAndPanicRecovery(t *testing.T) {
|
||||||
|
bus := core.NewEventBus()
|
||||||
|
|
||||||
|
errHandler1 := errors.New("handler 1 failed")
|
||||||
|
errHandler2 := errors.New("handler 2 failed")
|
||||||
|
|
||||||
|
bus.On("test:err", func() error {
|
||||||
|
return errHandler1
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.On("test:err", func() {
|
||||||
|
panic("something went horribly wrong")
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.On("test:err", func() error {
|
||||||
|
return errHandler2
|
||||||
|
})
|
||||||
|
|
||||||
|
err := bus.Emit(context.Background(), "test:err", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, errHandler1) || errors.Is(err, errHandler2))
|
||||||
|
assert.Contains(t, err.Error(), "handler 1 failed")
|
||||||
|
assert.Contains(t, err.Error(), "handler 2 failed")
|
||||||
|
assert.Contains(t, err.Error(), "panic")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBusInvalidHandlerPanics(t *testing.T) {
|
||||||
|
bus := core.NewEventBus()
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
bus.On("test:invalid", nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
bus.On("test:invalid", "not-a-func")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
// More than 2 arguments
|
||||||
|
bus.On("test:invalid", func(a, b, c string) {})
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
// 2 args, but first is not context
|
||||||
|
bus.On("test:invalid", func(a string, b int) {})
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
// More than 1 return value
|
||||||
|
bus.On("test:invalid", func() (int, error) { return 0, nil })
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
// Return value is not error
|
||||||
|
bus.On("test:invalid", func() int { return 0 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBusListenersCountAndDisposerIdempotence(t *testing.T) {
|
||||||
|
bus := core.NewEventBus()
|
||||||
|
|
||||||
|
assert.Equal(t, 0, bus.Listeners("topic1"))
|
||||||
|
|
||||||
|
d1 := bus.On("topic1", func() {})
|
||||||
|
d2 := bus.On("topic1", func() {})
|
||||||
|
assert.Equal(t, 2, bus.Listeners("topic1"))
|
||||||
|
|
||||||
|
_ = d1()
|
||||||
|
assert.Equal(t, 1, bus.Listeners("topic1"))
|
||||||
|
|
||||||
|
// Calling disposer again should be no-op
|
||||||
|
_ = d1()
|
||||||
|
assert.Equal(t, 1, bus.Listeners("topic1"))
|
||||||
|
|
||||||
|
_ = d2()
|
||||||
|
assert.Equal(t, 0, bus.Listeners("topic1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBusConcurrentAccess(t *testing.T) {
|
||||||
|
bus := core.NewEventBus()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
var receivedCount atomic.Int64
|
||||||
|
|
||||||
|
// Concurrently subscribe and emit
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
topic := fmt.Sprintf("topic:%d", idx%5)
|
||||||
|
disposer := bus.On(topic, func(ctx context.Context, e UserRegisteredEvent) error {
|
||||||
|
receivedCount.Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emit some events
|
||||||
|
_ = bus.Emit(context.Background(), topic, UserRegisteredEvent{UserID: fmt.Sprintf("u_%d", idx)})
|
||||||
|
|
||||||
|
// Randomly dispose
|
||||||
|
if idx%2 == 0 {
|
||||||
|
_ = disposer()
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
topic := fmt.Sprintf("topic:%d", idx%5)
|
||||||
|
_ = bus.Emit(context.Background(), topic, UserRegisteredEvent{UserID: fmt.Sprintf("u_%d", idx)})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
assert.Greater(t, receivedCount.Load(), int64(0))
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package extpoints_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/core"
|
||||||
|
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRouterExtension(t *testing.T) {
|
||||||
|
r := extpoints.NewRouterRegistry()
|
||||||
|
require.NotNil(t, r)
|
||||||
|
|
||||||
|
mGlobal := "global_middleware"
|
||||||
|
r.Use(mGlobal)
|
||||||
|
assert.Equal(t, []any{mGlobal}, r.Middlewares())
|
||||||
|
|
||||||
|
// Test root methods
|
||||||
|
hRoot := "root_handler"
|
||||||
|
r.GET("/", hRoot)
|
||||||
|
r.POST("/root_post", hRoot)
|
||||||
|
r.PUT("/root_put", hRoot)
|
||||||
|
r.DELETE("/root_del", hRoot)
|
||||||
|
r.PATCH("/root_patch", hRoot)
|
||||||
|
r.HEAD("/root_head", hRoot)
|
||||||
|
r.OPTIONS("/root_opt", hRoot)
|
||||||
|
anyRootDefs := r.Any("/root_any", hRoot)
|
||||||
|
assert.Len(t, anyRootDefs, 7)
|
||||||
|
|
||||||
|
// Group and Group.Use
|
||||||
|
mAPI := "api_middleware"
|
||||||
|
api := r.Group("/api/v1", mAPI)
|
||||||
|
api.Use("api_extra_middleware")
|
||||||
|
assert.Len(t, api.Middlewares(), 2)
|
||||||
|
|
||||||
|
hList := "list_orders_handler"
|
||||||
|
hCreate := "create_order_handler"
|
||||||
|
api.GET("/orders", hList)
|
||||||
|
api.POST("/orders", hCreate)
|
||||||
|
|
||||||
|
mAdmin := "admin_middleware"
|
||||||
|
admin := api.Group("admin", mAdmin)
|
||||||
|
|
||||||
|
hUserGet := "get_user_handler"
|
||||||
|
hUserPut := "put_user_handler"
|
||||||
|
hUserDel := "del_user_handler"
|
||||||
|
hUserPatch := "patch_user_handler"
|
||||||
|
hUserHead := "head_user_handler"
|
||||||
|
hUserOptions := "options_user_handler"
|
||||||
|
admin.GET("/users/:id", hUserGet)
|
||||||
|
admin.PUT("/users/:id", hUserPut)
|
||||||
|
admin.DELETE("/users/:id", hUserDel)
|
||||||
|
admin.PATCH("/users/:id", hUserPatch)
|
||||||
|
admin.HEAD("/users/:id", hUserHead)
|
||||||
|
admin.OPTIONS("/users/:id", hUserOptions)
|
||||||
|
|
||||||
|
hCustom := "custom_handler"
|
||||||
|
admin.Handle("CUSTOM", "/custom", hCustom)
|
||||||
|
|
||||||
|
hAny := "any_handler"
|
||||||
|
anyRoutes := admin.Any("/all", hAny)
|
||||||
|
assert.NotEmpty(t, anyRoutes)
|
||||||
|
|
||||||
|
// Group.Routes() returns root routes
|
||||||
|
assert.Equal(t, r.Routes(), admin.Routes())
|
||||||
|
|
||||||
|
routes := r.Routes()
|
||||||
|
|
||||||
|
// Verify route paths and middlewares
|
||||||
|
var foundOrderGet bool
|
||||||
|
var foundUserPut bool
|
||||||
|
for _, route := range routes {
|
||||||
|
if route.Method == "GET" && route.Path == "/api/v1/orders" {
|
||||||
|
foundOrderGet = true
|
||||||
|
assert.Equal(t, []any{mGlobal, mAPI, "api_extra_middleware"}, route.Middlewares)
|
||||||
|
assert.Equal(t, []any{hList}, route.Handlers)
|
||||||
|
}
|
||||||
|
if route.Method == "PUT" && route.Path == "/api/v1/admin/users/:id" {
|
||||||
|
foundUserPut = true
|
||||||
|
assert.Equal(t, []any{mGlobal, mAPI, "api_extra_middleware", mAdmin}, route.Middlewares)
|
||||||
|
assert.Equal(t, []any{hUserPut}, route.Handlers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, foundOrderGet)
|
||||||
|
assert.True(t, foundUserPut)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrationExtension(t *testing.T) {
|
||||||
|
m := extpoints.NewMigrationRegistry()
|
||||||
|
require.NotNil(t, m)
|
||||||
|
|
||||||
|
fs1 := fstest.MapFS{
|
||||||
|
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t1(id int);")},
|
||||||
|
}
|
||||||
|
fs2 := fstest.MapFS{
|
||||||
|
"custom/001_order.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t2(id int);")},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Register("auth", fs1)
|
||||||
|
m.Register("order", fs2, "custom")
|
||||||
|
|
||||||
|
// Update existing entry
|
||||||
|
fs1Updated := fstest.MapFS{
|
||||||
|
"migrations/002_update.sql": &fstest.MapFile{Data: []byte("ALTER TABLE t1 ADD col int;")},
|
||||||
|
}
|
||||||
|
m.Register("auth", fs1Updated, "")
|
||||||
|
|
||||||
|
entries := m.Entries()
|
||||||
|
require.Len(t, entries, 2)
|
||||||
|
assert.Equal(t, "auth", entries[0].PluginID)
|
||||||
|
assert.Equal(t, "migrations", entries[0].Dir)
|
||||||
|
assert.Equal(t, "order", entries[1].PluginID)
|
||||||
|
assert.Equal(t, "custom", entries[1].Dir)
|
||||||
|
|
||||||
|
authEntry, ok := m.Get("auth")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "auth", authEntry.PluginID)
|
||||||
|
|
||||||
|
_, ok = m.Get("non_existent")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskExtension(t *testing.T) {
|
||||||
|
tr := extpoints.NewTaskRegistry()
|
||||||
|
require.NotNil(t, tr)
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, payload []byte) error { return nil }
|
||||||
|
|
||||||
|
tr.Register("order:cancel_timeout", handler,
|
||||||
|
extpoints.WithTaskConcurrency(5),
|
||||||
|
extpoints.WithTaskRetry(3),
|
||||||
|
extpoints.WithTaskTimeout(10*time.Second),
|
||||||
|
extpoints.WithTaskMetadata("queue", "critical"),
|
||||||
|
nil, // test nil option
|
||||||
|
)
|
||||||
|
|
||||||
|
// Re-register to test update
|
||||||
|
tr.Register("order:cancel_timeout", handler,
|
||||||
|
extpoints.WithTaskConcurrency(10),
|
||||||
|
extpoints.WithTaskMetadata("queue", "high"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks := tr.Tasks()
|
||||||
|
require.Len(t, tasks, 1)
|
||||||
|
assert.Equal(t, "order:cancel_timeout", tasks[0].Pattern)
|
||||||
|
assert.Equal(t, 10, tasks[0].Concurrency)
|
||||||
|
assert.Equal(t, "high", tasks[0].Metadata["queue"])
|
||||||
|
|
||||||
|
task, ok := tr.Get("order:cancel_timeout")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "order:cancel_timeout", task.Pattern)
|
||||||
|
|
||||||
|
_, ok = tr.Get("unknown")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleExtension(t *testing.T) {
|
||||||
|
sr := extpoints.NewScheduleRegistry()
|
||||||
|
require.NotNil(t, sr)
|
||||||
|
|
||||||
|
type ReportPayload struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
sr.RegisterCron("0 2 * * *", "report:daily_summary", ReportPayload{Type: "daily"})
|
||||||
|
sr.Register("@every 1h", "cleanup:expired_sessions", nil,
|
||||||
|
extpoints.WithScheduleOption("retry", 2),
|
||||||
|
nil, // test nil option
|
||||||
|
)
|
||||||
|
|
||||||
|
// Re-register to test update
|
||||||
|
sr.RegisterCron("0 3 * * *", "report:daily_summary", ReportPayload{Type: "all"})
|
||||||
|
|
||||||
|
schedules := sr.Schedules()
|
||||||
|
require.Len(t, schedules, 2)
|
||||||
|
|
||||||
|
assert.Equal(t, "0 3 * * *", schedules[0].Spec)
|
||||||
|
assert.Equal(t, "report:daily_summary", schedules[0].TaskType)
|
||||||
|
assert.Equal(t, ReportPayload{Type: "all"}, schedules[0].Payload)
|
||||||
|
|
||||||
|
assert.Equal(t, "@every 1h", schedules[1].Spec)
|
||||||
|
assert.Equal(t, "cleanup:expired_sessions", schedules[1].TaskType)
|
||||||
|
assert.Equal(t, 2, schedules[1].Options["retry"])
|
||||||
|
|
||||||
|
sched, ok := sr.Get("report:daily_summary")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "0 3 * * *", sched.Spec)
|
||||||
|
|
||||||
|
_, ok = sr.Get("unknown")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingExtension(t *testing.T) {
|
||||||
|
sr := extpoints.NewSettingRegistry()
|
||||||
|
require.NotNil(t, sr)
|
||||||
|
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
sr.Register(extpoints.SettingSchema{}) // empty key panics
|
||||||
|
})
|
||||||
|
|
||||||
|
sr.Register(extpoints.SettingSchema{
|
||||||
|
Key: "order.auto_cancel_mins",
|
||||||
|
Default: 15,
|
||||||
|
Description: "Order auto cancellation timeout in minutes",
|
||||||
|
Category: "order",
|
||||||
|
Public: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-register to test update
|
||||||
|
sr.Register(extpoints.SettingSchema{
|
||||||
|
Key: "order.auto_cancel_mins",
|
||||||
|
Default: 30,
|
||||||
|
Description: "Updated timeout",
|
||||||
|
})
|
||||||
|
|
||||||
|
sr.Register(extpoints.SettingSchema{
|
||||||
|
Key: "auth.jwt_secret",
|
||||||
|
Default: "default-secret",
|
||||||
|
Description: "JWT secret key",
|
||||||
|
Category: "auth",
|
||||||
|
ReadOnly: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
schemas := sr.Schemas()
|
||||||
|
require.Len(t, schemas, 2)
|
||||||
|
|
||||||
|
schema, ok := sr.Get("order.auto_cancel_mins")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, 30, schema.Default)
|
||||||
|
|
||||||
|
_, ok = sr.Get("unknown")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextExtensionPointsIntegration(t *testing.T) {
|
||||||
|
ctx := core.NewContext(context.Background())
|
||||||
|
|
||||||
|
require.NotNil(t, ctx.Events())
|
||||||
|
require.NotNil(t, ctx.Router())
|
||||||
|
require.NotNil(t, ctx.Migrations())
|
||||||
|
require.NotNil(t, ctx.Tasks())
|
||||||
|
require.NotNil(t, ctx.Task())
|
||||||
|
require.NotNil(t, ctx.Schedules())
|
||||||
|
require.NotNil(t, ctx.Schedule())
|
||||||
|
require.NotNil(t, ctx.Settings())
|
||||||
|
require.NotNil(t, ctx.Setting())
|
||||||
|
|
||||||
|
// Register from child context and verify shared application registry
|
||||||
|
child := ctx.Fork()
|
||||||
|
child.Router().GET("/ping", "pong_handler")
|
||||||
|
child.Task().Register("sample:task", "handler")
|
||||||
|
child.Schedule().RegisterCron("@hourly", "sample:cron", nil)
|
||||||
|
child.Settings().Register(extpoints.SettingSchema{
|
||||||
|
Key: "app.name",
|
||||||
|
Default: "Wavelet",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Len(t, ctx.Router().Routes(), 1)
|
||||||
|
assert.Len(t, ctx.Tasks().Tasks(), 1)
|
||||||
|
assert.Len(t, ctx.Schedules().Schedules(), 1)
|
||||||
|
assert.Len(t, ctx.Settings().Schemas(), 1)
|
||||||
|
|
||||||
|
// Child and root events
|
||||||
|
var eventReceived bool
|
||||||
|
child.Events().On("app:ready", func() {
|
||||||
|
eventReceived = true
|
||||||
|
})
|
||||||
|
err := ctx.Events().Emit(context.Background(), "app:ready", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, eventReceived)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package extpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MigrationEntry contains the migration filesystem and configuration for a plugin.
|
||||||
|
type MigrationEntry struct {
|
||||||
|
PluginID string
|
||||||
|
FS fs.FS
|
||||||
|
Dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrationExtension defines the interface for registering and querying plugin migrations.
|
||||||
|
type MigrationExtension interface {
|
||||||
|
Register(pluginID string, fsys fs.FS, dir ...string)
|
||||||
|
Entries() []MigrationEntry
|
||||||
|
Get(pluginID string) (MigrationEntry, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrationRegistry collects and stores migration entries from plugins.
|
||||||
|
type MigrationRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries []MigrationEntry
|
||||||
|
lookup map[string]MigrationEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMigrationRegistry creates a new migration registry.
|
||||||
|
func NewMigrationRegistry() *MigrationRegistry {
|
||||||
|
return &MigrationRegistry{
|
||||||
|
lookup: make(map[string]MigrationEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a migration entry for a plugin.
|
||||||
|
// If dir is not specified, it defaults to "migrations".
|
||||||
|
func (m *MigrationRegistry) Register(pluginID string, fsys fs.FS, dir ...string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
migrationDir := "migrations"
|
||||||
|
if len(dir) > 0 && dir[0] != "" {
|
||||||
|
migrationDir = dir[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := MigrationEntry{
|
||||||
|
PluginID: pluginID,
|
||||||
|
FS: fsys,
|
||||||
|
Dir: migrationDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If entry already exists, update in-place; otherwise append
|
||||||
|
if _, exists := m.lookup[pluginID]; exists {
|
||||||
|
for i, e := range m.entries {
|
||||||
|
if e.PluginID == pluginID {
|
||||||
|
m.entries[i] = entry
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.entries = append(m.entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.lookup[pluginID] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries returns a copy of all registered migration entries in registration order.
|
||||||
|
func (m *MigrationRegistry) Entries() []MigrationEntry {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
res := make([]MigrationEntry, len(m.entries))
|
||||||
|
copy(res, m.entries)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves the migration entry for a specific plugin ID.
|
||||||
|
func (m *MigrationRegistry) Get(pluginID string) (MigrationEntry, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
e, ok := m.lookup[pluginID]
|
||||||
|
return e, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package extpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouteDefinition holds the metadata and handler list for a single HTTP route.
|
||||||
|
type RouteDefinition struct {
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
Handlers []any
|
||||||
|
Middlewares []any
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouterExtension defines the interface for registering routes and middlewares.
|
||||||
|
type RouterExtension interface {
|
||||||
|
Use(middlewares ...any)
|
||||||
|
Group(prefix string, middlewares ...any) RouterExtension
|
||||||
|
Handle(method string, path string, handlers ...any) RouteDefinition
|
||||||
|
GET(path string, handlers ...any) RouteDefinition
|
||||||
|
POST(path string, handlers ...any) RouteDefinition
|
||||||
|
PUT(path string, handlers ...any) RouteDefinition
|
||||||
|
DELETE(path string, handlers ...any) RouteDefinition
|
||||||
|
PATCH(path string, handlers ...any) RouteDefinition
|
||||||
|
HEAD(path string, handlers ...any) RouteDefinition
|
||||||
|
OPTIONS(path string, handlers ...any) RouteDefinition
|
||||||
|
Any(path string, handlers ...any) []RouteDefinition
|
||||||
|
Routes() []RouteDefinition
|
||||||
|
Middlewares() []any
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouterRegistry implements RouterExtension as the root route and middleware collector.
|
||||||
|
type RouterRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
routes []RouteDefinition
|
||||||
|
middlewares []any
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouterRegistry creates a new root router collector.
|
||||||
|
func NewRouterRegistry() *RouterRegistry {
|
||||||
|
return &RouterRegistry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use registers global middlewares to the router.
|
||||||
|
func (r *RouterRegistry) Use(middlewares ...any) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.middlewares = append(r.middlewares, middlewares...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middlewares returns a copy of registered root middlewares.
|
||||||
|
func (r *RouterRegistry) Middlewares() []any {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
res := make([]any, len(r.middlewares))
|
||||||
|
copy(res, r.middlewares)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group creates a new RouteGroup under the router.
|
||||||
|
func (r *RouterRegistry) Group(prefix string, middlewares ...any) RouterExtension {
|
||||||
|
return &RouterGroup{
|
||||||
|
registry: r,
|
||||||
|
prefix: cleanPath(prefix),
|
||||||
|
middlewares: middlewares,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle registers a route with a custom HTTP method and handlers.
|
||||||
|
func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefinition {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
rd := RouteDefinition{
|
||||||
|
Method: strings.ToUpper(method),
|
||||||
|
Path: cleanPath(path),
|
||||||
|
Handlers: handlers,
|
||||||
|
Middlewares: append([]any(nil), r.middlewares...),
|
||||||
|
}
|
||||||
|
r.routes = append(r.routes, rd)
|
||||||
|
return rd
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET registers a GET route.
|
||||||
|
func (r *RouterRegistry) GET(path string, handlers ...any) RouteDefinition {
|
||||||
|
return r.Handle("GET", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST registers a POST route.
|
||||||
|
func (r *RouterRegistry) POST(path string, handlers ...any) RouteDefinition {
|
||||||
|
return r.Handle("POST", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT registers a PUT route.
|
||||||
|
func (r *RouterRegistry) PUT(path string, handlers ...any) RouteDefinition {
|
||||||
|
return r.Handle("PUT", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE registers a DELETE route.
|
||||||
|
func (r *RouterRegistry) DELETE(path string, handlers ...any) RouteDefinition {
|
||||||
|
return r.Handle("DELETE", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH registers a PATCH route.
|
||||||
|
func (r *RouterRegistry) PATCH(path string, handlers ...any) RouteDefinition {
|
||||||
|
return r.Handle("PATCH", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEAD registers a HEAD route.
|
||||||
|
func (r *RouterRegistry) HEAD(path string, handlers ...any) RouteDefinition {
|
||||||
|
return r.Handle("HEAD", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPTIONS registers an OPTIONS route.
|
||||||
|
func (r *RouterRegistry) OPTIONS(path string, handlers ...any) RouteDefinition {
|
||||||
|
return r.Handle("OPTIONS", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any registers a route for standard HTTP methods.
|
||||||
|
func (r *RouterRegistry) Any(path string, handlers ...any) []RouteDefinition {
|
||||||
|
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
|
||||||
|
defs := make([]RouteDefinition, 0, len(methods))
|
||||||
|
for _, m := range methods {
|
||||||
|
defs = append(defs, r.Handle(m, path, handlers...))
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes returns a copy of all collected RouteDefinitions.
|
||||||
|
func (r *RouterRegistry) Routes() []RouteDefinition {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
res := make([]RouteDefinition, len(r.routes))
|
||||||
|
copy(res, r.routes)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouterGroup represents a scoped route group with a path prefix and group-level middlewares.
|
||||||
|
type RouterGroup struct {
|
||||||
|
registry *RouterRegistry
|
||||||
|
prefix string
|
||||||
|
middlewares []any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use adds middlewares to this group.
|
||||||
|
func (g *RouterGroup) Use(middlewares ...any) {
|
||||||
|
g.middlewares = append(g.middlewares, middlewares...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group creates a nested RouteGroup.
|
||||||
|
func (g *RouterGroup) Group(prefix string, middlewares ...any) RouterExtension {
|
||||||
|
combinedPrefix := joinPaths(g.prefix, prefix)
|
||||||
|
combinedMiddlewares := make([]any, 0, len(g.middlewares)+len(middlewares))
|
||||||
|
combinedMiddlewares = append(combinedMiddlewares, g.middlewares...)
|
||||||
|
combinedMiddlewares = append(combinedMiddlewares, middlewares...)
|
||||||
|
|
||||||
|
return &RouterGroup{
|
||||||
|
registry: g.registry,
|
||||||
|
prefix: combinedPrefix,
|
||||||
|
middlewares: combinedMiddlewares,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle registers a route under this group.
|
||||||
|
func (g *RouterGroup) Handle(method, path string, handlers ...any) RouteDefinition {
|
||||||
|
g.registry.mu.Lock()
|
||||||
|
defer g.registry.mu.Unlock()
|
||||||
|
|
||||||
|
fullPath := joinPaths(g.prefix, path)
|
||||||
|
|
||||||
|
allMiddlewares := make([]any, 0, len(g.registry.middlewares)+len(g.middlewares))
|
||||||
|
allMiddlewares = append(allMiddlewares, g.registry.middlewares...)
|
||||||
|
allMiddlewares = append(allMiddlewares, g.middlewares...)
|
||||||
|
|
||||||
|
rd := RouteDefinition{
|
||||||
|
Method: strings.ToUpper(method),
|
||||||
|
Path: fullPath,
|
||||||
|
Handlers: handlers,
|
||||||
|
Middlewares: allMiddlewares,
|
||||||
|
}
|
||||||
|
g.registry.routes = append(g.registry.routes, rd)
|
||||||
|
return rd
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET registers a GET route in this group.
|
||||||
|
func (g *RouterGroup) GET(path string, handlers ...any) RouteDefinition {
|
||||||
|
return g.Handle("GET", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST registers a POST route in this group.
|
||||||
|
func (g *RouterGroup) POST(path string, handlers ...any) RouteDefinition {
|
||||||
|
return g.Handle("POST", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT registers a PUT route in this group.
|
||||||
|
func (g *RouterGroup) PUT(path string, handlers ...any) RouteDefinition {
|
||||||
|
return g.Handle("PUT", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE registers a DELETE route in this group.
|
||||||
|
func (g *RouterGroup) DELETE(path string, handlers ...any) RouteDefinition {
|
||||||
|
return g.Handle("DELETE", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH registers a PATCH route in this group.
|
||||||
|
func (g *RouterGroup) PATCH(path string, handlers ...any) RouteDefinition {
|
||||||
|
return g.Handle("PATCH", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEAD registers a HEAD route in this group.
|
||||||
|
func (g *RouterGroup) HEAD(path string, handlers ...any) RouteDefinition {
|
||||||
|
return g.Handle("HEAD", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPTIONS registers an OPTIONS route in this group.
|
||||||
|
func (g *RouterGroup) OPTIONS(path string, handlers ...any) RouteDefinition {
|
||||||
|
return g.Handle("OPTIONS", path, handlers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any registers a route in this group for standard HTTP methods.
|
||||||
|
func (g *RouterGroup) Any(path string, handlers ...any) []RouteDefinition {
|
||||||
|
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
|
||||||
|
defs := make([]RouteDefinition, 0, len(methods))
|
||||||
|
for _, m := range methods {
|
||||||
|
defs = append(defs, g.Handle(m, path, handlers...))
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes returns all routes from the parent registry.
|
||||||
|
func (g *RouterGroup) Routes() []RouteDefinition {
|
||||||
|
return g.registry.Routes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middlewares returns a copy of the group's middlewares.
|
||||||
|
func (g *RouterGroup) Middlewares() []any {
|
||||||
|
res := make([]any, len(g.middlewares))
|
||||||
|
copy(res, g.middlewares)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanPath(p string) string {
|
||||||
|
if p == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(p, "/") {
|
||||||
|
p = "/" + p
|
||||||
|
}
|
||||||
|
if len(p) > 1 && strings.HasSuffix(p, "/") {
|
||||||
|
p = strings.TrimSuffix(p, "/")
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPaths(base, relative string) string {
|
||||||
|
if base == "" || base == "/" {
|
||||||
|
return cleanPath(relative)
|
||||||
|
}
|
||||||
|
if relative == "" || relative == "/" {
|
||||||
|
return cleanPath(base)
|
||||||
|
}
|
||||||
|
base = strings.TrimSuffix(base, "/")
|
||||||
|
relative = strings.TrimPrefix(relative, "/")
|
||||||
|
return cleanPath(base + "/" + relative)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package extpoints
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// ScheduleDefinition holds the configuration for a scheduled/cron task.
|
||||||
|
type ScheduleDefinition struct {
|
||||||
|
Spec string
|
||||||
|
TaskType string
|
||||||
|
Payload any
|
||||||
|
Options map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleOption configures a ScheduleDefinition.
|
||||||
|
type ScheduleOption func(*ScheduleDefinition)
|
||||||
|
|
||||||
|
// WithScheduleOption adds a custom option to the schedule definition.
|
||||||
|
func WithScheduleOption(key string, val any) ScheduleOption {
|
||||||
|
return func(sd *ScheduleDefinition) {
|
||||||
|
if sd.Options == nil {
|
||||||
|
sd.Options = make(map[string]any)
|
||||||
|
}
|
||||||
|
sd.Options[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleExtension defines the interface for registering and querying cron/scheduled tasks.
|
||||||
|
type ScheduleExtension interface {
|
||||||
|
Register(spec string, taskType string, payload any, opts ...ScheduleOption)
|
||||||
|
RegisterCron(spec string, taskType string, payload any, opts ...ScheduleOption)
|
||||||
|
Schedules() []ScheduleDefinition
|
||||||
|
Get(taskType string) (ScheduleDefinition, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleRegistry collects and manages schedule registrations.
|
||||||
|
type ScheduleRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
schedules []ScheduleDefinition
|
||||||
|
lookup map[string]ScheduleDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScheduleRegistry creates a new schedule registry.
|
||||||
|
func NewScheduleRegistry() *ScheduleRegistry {
|
||||||
|
return &ScheduleRegistry{
|
||||||
|
lookup: make(map[string]ScheduleDefinition),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a schedule definition.
|
||||||
|
func (s *ScheduleRegistry) Register(spec string, taskType string, payload any, opts ...ScheduleOption) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
sd := ScheduleDefinition{
|
||||||
|
Spec: spec,
|
||||||
|
TaskType: taskType,
|
||||||
|
Payload: payload,
|
||||||
|
Options: make(map[string]any),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(&sd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := s.lookup[taskType]; exists {
|
||||||
|
for i, item := range s.schedules {
|
||||||
|
if item.TaskType == taskType {
|
||||||
|
s.schedules[i] = sd
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.schedules = append(s.schedules, sd)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lookup[taskType] = sd
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterCron is an alias for Register.
|
||||||
|
func (s *ScheduleRegistry) RegisterCron(spec string, taskType string, payload any, opts ...ScheduleOption) {
|
||||||
|
s.Register(spec, taskType, payload, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedules returns a copy of all registered ScheduleDefinitions.
|
||||||
|
func (s *ScheduleRegistry) Schedules() []ScheduleDefinition {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
res := make([]ScheduleDefinition, len(s.schedules))
|
||||||
|
copy(res, s.schedules)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a schedule definition by its task type.
|
||||||
|
func (s *ScheduleRegistry) Get(taskType string) (ScheduleDefinition, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
sd, ok := s.lookup[taskType]
|
||||||
|
return sd, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package extpoints
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// SettingSchema defines the configuration schema and metadata for a system or plugin setting.
|
||||||
|
type SettingSchema struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Default any `json:"default"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
ReadOnly bool `json:"read_only,omitempty"`
|
||||||
|
Public bool `json:"public,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Validation string `json:"validation,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingExtension defines the interface for registering and querying setting configuration schemas.
|
||||||
|
type SettingExtension interface {
|
||||||
|
Register(schema SettingSchema)
|
||||||
|
Schemas() []SettingSchema
|
||||||
|
Get(key string) (SettingSchema, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingRegistry collects and manages setting configuration schemas.
|
||||||
|
type SettingRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
schemas []SettingSchema
|
||||||
|
lookup map[string]SettingSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSettingRegistry creates a new setting schema registry.
|
||||||
|
func NewSettingRegistry() *SettingRegistry {
|
||||||
|
return &SettingRegistry{
|
||||||
|
lookup: make(map[string]SettingSchema),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers a SettingSchema into the registry.
|
||||||
|
// Panics if the schema Key is empty.
|
||||||
|
func (s *SettingRegistry) Register(schema SettingSchema) {
|
||||||
|
if schema.Key == "" {
|
||||||
|
panic("core/extpoints: setting schema key cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := s.lookup[schema.Key]; exists {
|
||||||
|
for i, item := range s.schemas {
|
||||||
|
if item.Key == schema.Key {
|
||||||
|
s.schemas[i] = schema
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.schemas = append(s.schemas, schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lookup[schema.Key] = schema
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schemas returns a copy of all registered SettingSchemas.
|
||||||
|
func (s *SettingRegistry) Schemas() []SettingSchema {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
res := make([]SettingSchema, len(s.schemas))
|
||||||
|
copy(res, s.schemas)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a SettingSchema by its key.
|
||||||
|
func (s *SettingRegistry) Get(key string) (SettingSchema, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
schema, ok := s.lookup[key]
|
||||||
|
return schema, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package extpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskDefinition holds the definition and runtime options for an asynchronous background task.
|
||||||
|
type TaskDefinition struct {
|
||||||
|
Pattern string
|
||||||
|
Handler any
|
||||||
|
Concurrency int
|
||||||
|
Retry int
|
||||||
|
Timeout time.Duration
|
||||||
|
Metadata map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskOption configures a TaskDefinition.
|
||||||
|
type TaskOption func(*TaskDefinition)
|
||||||
|
|
||||||
|
// WithTaskConcurrency sets the concurrency limit for the task.
|
||||||
|
func WithTaskConcurrency(concurrency int) TaskOption {
|
||||||
|
return func(td *TaskDefinition) {
|
||||||
|
td.Concurrency = concurrency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTaskRetry sets the maximum retry count for the task.
|
||||||
|
func WithTaskRetry(retry int) TaskOption {
|
||||||
|
return func(td *TaskDefinition) {
|
||||||
|
td.Retry = retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTaskTimeout sets the execution timeout for the task.
|
||||||
|
func WithTaskTimeout(timeout time.Duration) TaskOption {
|
||||||
|
return func(td *TaskDefinition) {
|
||||||
|
td.Timeout = timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTaskMetadata adds a key-value pair to the task metadata.
|
||||||
|
func WithTaskMetadata(key string, val any) TaskOption {
|
||||||
|
return func(td *TaskDefinition) {
|
||||||
|
if td.Metadata == nil {
|
||||||
|
td.Metadata = make(map[string]any)
|
||||||
|
}
|
||||||
|
td.Metadata[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskExtension defines the interface for registering and querying background task handlers.
|
||||||
|
type TaskExtension interface {
|
||||||
|
Register(pattern string, handler any, opts ...TaskOption)
|
||||||
|
Tasks() []TaskDefinition
|
||||||
|
Get(pattern string) (TaskDefinition, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskRegistry collects and manages task registrations.
|
||||||
|
type TaskRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
tasks []TaskDefinition
|
||||||
|
lookup map[string]TaskDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskRegistry creates a new task registry.
|
||||||
|
func NewTaskRegistry() *TaskRegistry {
|
||||||
|
return &TaskRegistry{
|
||||||
|
lookup: make(map[string]TaskDefinition),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers a task pattern and its handler with optional configuration.
|
||||||
|
func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
td := TaskDefinition{
|
||||||
|
Pattern: pattern,
|
||||||
|
Handler: handler,
|
||||||
|
Metadata: make(map[string]any),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(&td)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := t.lookup[pattern]; exists {
|
||||||
|
for i, item := range t.tasks {
|
||||||
|
if item.Pattern == pattern {
|
||||||
|
t.tasks[i] = td
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.tasks = append(t.tasks, td)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.lookup[pattern] = td
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks returns a copy of all registered TaskDefinitions.
|
||||||
|
func (t *TaskRegistry) Tasks() []TaskDefinition {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
res := make([]TaskDefinition, len(t.tasks))
|
||||||
|
copy(res, t.tasks)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a task definition by its pattern.
|
||||||
|
func (t *TaskRegistry) Get(pattern string) (TaskDefinition, bool) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
td, ok := t.lookup[pattern]
|
||||||
|
return td, ok
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package core
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Standard sentinel errors returned by core operations.
|
// Standard sentinel errors returned by core operations.
|
||||||
@@ -69,3 +71,19 @@ type Driver interface {
|
|||||||
|
|
||||||
// Disposer is a cleanup function executed when a Context is disposed.
|
// Disposer is a cleanup function executed when a Context is disposed.
|
||||||
type Disposer func() error
|
type Disposer func() error
|
||||||
|
|
||||||
|
// Re-exported extension point types from core/extpoints for convenient usage.
|
||||||
|
type (
|
||||||
|
RouterExtension = extpoints.RouterExtension
|
||||||
|
RouteDefinition = extpoints.RouteDefinition
|
||||||
|
MigrationExtension = extpoints.MigrationExtension
|
||||||
|
MigrationEntry = extpoints.MigrationEntry
|
||||||
|
TaskExtension = extpoints.TaskExtension
|
||||||
|
TaskDefinition = extpoints.TaskDefinition
|
||||||
|
TaskOption = extpoints.TaskOption
|
||||||
|
ScheduleExtension = extpoints.ScheduleExtension
|
||||||
|
ScheduleDefinition = extpoints.ScheduleDefinition
|
||||||
|
ScheduleOption = extpoints.ScheduleOption
|
||||||
|
SettingExtension = extpoints.SettingExtension
|
||||||
|
SettingSchema = extpoints.SettingSchema
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user