mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
refactor(layout): consolidate backend codebase into backend/ package and clean root directory
- Moved cmd/, core/, plugins/, pkg/, downstream/, and main.go into backend/ directory - Batch updated all Go source files to import github.com/Rain-kl/Wavelet/backend/... - Updated Makefile, scripts/swagger.sh, architecture guards, and platform skills - Passed all quality gates (100% tests, 0 lint issues, clean build)
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultShutdownTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// AppOption configures an App instance during construction.
|
||||
type AppOption func(*App)
|
||||
|
||||
// WithContext sets a custom root Context for the App.
|
||||
func WithContext(ctx *Context) AppOption {
|
||||
return func(a *App) {
|
||||
if ctx != nil {
|
||||
a.ctx = ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithProfile sets the runtime profile for the App.
|
||||
func WithProfile(profile Profile) AppOption {
|
||||
return func(a *App) {
|
||||
a.profile = normalizeProfile(profile)
|
||||
}
|
||||
}
|
||||
|
||||
// WithPlugins registers initial plugins for the App.
|
||||
func WithPlugins(plugins ...Plugin) AppOption {
|
||||
return func(a *App) {
|
||||
a.Use(plugins...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithMigrationEngine sets the database migration engine for the App.
|
||||
func WithMigrationEngine(engine MigrationEngine) AppOption {
|
||||
return func(a *App) {
|
||||
a.migrationEngine = engine
|
||||
}
|
||||
}
|
||||
|
||||
// WithMigrationRunner sets the migration runner function for the App.
|
||||
func WithMigrationRunner(runner MigrationRunner) AppOption {
|
||||
return func(a *App) {
|
||||
a.migrationEngine = runner
|
||||
}
|
||||
}
|
||||
|
||||
// WithShutdownTimeout sets the fallback timeout for graceful application shutdown.
|
||||
func WithShutdownTimeout(timeout time.Duration) AppOption {
|
||||
return func(a *App) {
|
||||
if timeout > 0 {
|
||||
a.shutdownTimeout = timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App is the unified assembly entrypoint and runtime aspect dispatcher of the Cordis micro-kernel.
|
||||
// It manages plugin collection, dependency mounting, migration execution, profile-based driver startup,
|
||||
// and graceful signal-driven LIFO shutdown.
|
||||
type App struct {
|
||||
mu sync.RWMutex
|
||||
ctx *Context
|
||||
profile Profile
|
||||
plugins []Plugin
|
||||
pluginMap map[string]Plugin
|
||||
applied bool
|
||||
running bool
|
||||
startedDrivers []Driver
|
||||
migrationEngine MigrationEngine
|
||||
shutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewApp creates a new Cordis application instance with default options.
|
||||
func NewApp(opts ...AppOption) *App {
|
||||
app := &App{
|
||||
ctx: NewContext(context.Background()),
|
||||
profile: ProfileAll,
|
||||
pluginMap: make(map[string]Plugin),
|
||||
shutdownTimeout: defaultShutdownTimeout,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(app)
|
||||
}
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// Context returns the root micro-kernel Context of the application.
|
||||
func (a *App) Context() *Context {
|
||||
return a.ctx
|
||||
}
|
||||
|
||||
// Profile returns the current runtime profile of the application.
|
||||
func (a *App) Profile() Profile {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.profile
|
||||
}
|
||||
|
||||
// WithProfile sets the application runtime profile and returns the App for fluent chaining.
|
||||
func (a *App) WithProfile(profile Profile) *App {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.profile = normalizeProfile(profile)
|
||||
return a
|
||||
}
|
||||
|
||||
// SetProfile sets the application runtime profile.
|
||||
func (a *App) SetProfile(profile Profile) *App {
|
||||
return a.WithProfile(profile)
|
||||
}
|
||||
|
||||
// Use registers one or more plugins into the application in registration order.
|
||||
// Duplicate plugins (by Name) update existing registrations in-place to preserve order.
|
||||
func (a *App) Use(plugins ...Plugin) *App {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
for _, p := range plugins {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
name := p.Name()
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := a.pluginMap[name]; exists {
|
||||
for i, existing := range a.plugins {
|
||||
if existing.Name() == name {
|
||||
a.plugins[i] = p
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.plugins = append(a.plugins, p)
|
||||
}
|
||||
a.pluginMap[name] = p
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// Plugins returns a copy of all registered plugins in registration order.
|
||||
func (a *App) Plugins() []Plugin {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
res := make([]Plugin, len(a.plugins))
|
||||
copy(res, a.plugins)
|
||||
return res
|
||||
}
|
||||
|
||||
// Plugin retrieves a registered plugin by its unique name.
|
||||
func (a *App) Plugin(name string) (Plugin, bool) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
p, ok := a.pluginMap[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// SetMigrationEngine sets the migration engine for the application.
|
||||
func (a *App) SetMigrationEngine(engine MigrationEngine) *App {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.migrationEngine = engine
|
||||
return a
|
||||
}
|
||||
|
||||
// SetMigrationRunner sets the migration runner function for the application.
|
||||
func (a *App) SetMigrationRunner(runner MigrationRunner) *App {
|
||||
return a.SetMigrationEngine(runner)
|
||||
}
|
||||
|
||||
// ApplyPlugins applies all registered plugins on the application Context.
|
||||
// It is idempotent and only applies plugins once per App instance.
|
||||
func (a *App) ApplyPlugins() error {
|
||||
a.mu.Lock()
|
||||
if a.applied {
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
a.applied = true
|
||||
plugins := make([]Plugin, len(a.plugins))
|
||||
copy(plugins, a.plugins)
|
||||
a.mu.Unlock()
|
||||
|
||||
for _, p := range plugins {
|
||||
if err := p.Apply(a.ctx); err != nil {
|
||||
return fmt.Errorf("core: apply plugin %q failed: %w", p.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunMigrations dispatches migration execution across all registered plugin migration entries.
|
||||
func (a *App) RunMigrations() error {
|
||||
entries := a.ctx.Migrations().Entries()
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
engine := a.migrationEngine
|
||||
a.mu.RUnlock()
|
||||
|
||||
if engine == nil {
|
||||
// Attempt to resolve from IoC container
|
||||
if resolved, err := Inject[MigrationEngine](a.ctx); err == nil && resolved != nil {
|
||||
engine = resolved
|
||||
}
|
||||
}
|
||||
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := engine.Migrate(a.ctx, entries); err != nil {
|
||||
return fmt.Errorf("core: migration failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start executes the application boot pipeline:
|
||||
// 1. Applies all registered plugins to populate services, routes, tasks, and drivers.
|
||||
// 2. Dispatches database migrations via MigrationEngine.
|
||||
// 3. Filters and starts drivers matching the active Profile.
|
||||
// 4. Emits "app:ready" on the EventBus.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (a *App) Start(ctx ...context.Context) error {
|
||||
a.mu.Lock()
|
||||
if a.running {
|
||||
a.mu.Unlock()
|
||||
return ErrAppRunning
|
||||
}
|
||||
a.running = true
|
||||
a.mu.Unlock()
|
||||
|
||||
var baseCtx context.Context
|
||||
switch {
|
||||
case len(ctx) > 0 && ctx[0] != nil:
|
||||
baseCtx = ctx[0]
|
||||
case a.ctx != nil:
|
||||
baseCtx = a.ctx.GoContext()
|
||||
default:
|
||||
baseCtx = context.Background()
|
||||
}
|
||||
|
||||
// 1. Apply plugins
|
||||
if err := a.ApplyPlugins(); err != nil {
|
||||
a.mu.Lock()
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Run migrations
|
||||
if err := a.RunMigrations(); err != nil {
|
||||
a.mu.Lock()
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Filter drivers matching active profile
|
||||
a.mu.RLock()
|
||||
prof := a.profile
|
||||
a.mu.RUnlock()
|
||||
|
||||
allDrivers := a.ctx.Drivers()
|
||||
var driversToStart []Driver
|
||||
for _, d := range allDrivers {
|
||||
if matchesProfile(prof, d.Type()) {
|
||||
driversToStart = append(driversToStart, d)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Start matching drivers
|
||||
for _, d := range driversToStart {
|
||||
if err := d.Start(baseCtx); err != nil {
|
||||
// Rollback already started drivers in reverse order
|
||||
a.mu.Lock()
|
||||
started := a.startedDrivers
|
||||
a.startedDrivers = nil
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
|
||||
for i := len(started) - 1; i >= 0; i-- {
|
||||
_ = started[i].Stop(context.Background())
|
||||
}
|
||||
|
||||
return fmt.Errorf("core: start driver %s failed: %w", d.Type(), err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.startedDrivers = append(a.startedDrivers, d)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// 5. Emit app:ready event
|
||||
_ = a.ctx.Events().Emit(baseCtx, "app:ready", a)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the application:
|
||||
// 1. Emits "app:stopping" on the EventBus.
|
||||
// 2. Stops all started drivers in LIFO (reverse) order.
|
||||
// 3. Disposes the Context (running registered OnDispose callbacks in LIFO order).
|
||||
// 4. Emits "app:stopped" on the EventBus.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (a *App) Stop(ctx ...context.Context) error {
|
||||
a.mu.Lock()
|
||||
if !a.running {
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
a.running = false
|
||||
started := a.startedDrivers
|
||||
a.startedDrivers = nil
|
||||
timeout := a.shutdownTimeout
|
||||
a.mu.Unlock()
|
||||
|
||||
var shutdownCtx context.Context
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
shutdownCtx = ctx[0]
|
||||
} else {
|
||||
var cancel context.CancelFunc
|
||||
shutdownCtx, cancel = context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
_ = a.ctx.Events().Emit(shutdownCtx, "app:stopping", a)
|
||||
|
||||
var errs []error
|
||||
|
||||
// 1. Stop drivers in reverse order
|
||||
for i := len(started) - 1; i >= 0; i-- {
|
||||
d := started[i]
|
||||
if err := d.Stop(shutdownCtx); err != nil {
|
||||
errs = append(errs, fmt.Errorf("core: stop driver %s failed: %w", d.Type(), err))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Dispose context
|
||||
if a.ctx != nil && !a.ctx.IsDisposed() {
|
||||
if err := a.ctx.Dispose(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("core: dispose context failed: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
_ = a.ctx.Events().Emit(shutdownCtx, "app:stopped", a)
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Run starts the application and blocks until an OS signal (SIGINT, SIGTERM) or context cancellation is received,
|
||||
// then executes graceful shutdown.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (a *App) Run(ctx ...context.Context) error {
|
||||
var parent context.Context
|
||||
switch {
|
||||
case len(ctx) > 0 && ctx[0] != nil:
|
||||
parent = ctx[0]
|
||||
case a.ctx != nil:
|
||||
parent = a.ctx.GoContext()
|
||||
default:
|
||||
parent = context.Background()
|
||||
}
|
||||
|
||||
sigCtx, stopSignals := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
|
||||
defer stopSignals()
|
||||
|
||||
if err := a.Start(sigCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for OS signal or context cancellation
|
||||
<-sigCtx.Done()
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
return a.Stop(shutdownCtx)
|
||||
}
|
||||
|
||||
// IsRunning returns whether the application is currently running.
|
||||
func (a *App) IsRunning() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.running
|
||||
}
|
||||
|
||||
// StartedDrivers returns a copy of currently running drivers.
|
||||
func (a *App) StartedDrivers() []Driver {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
res := make([]Driver, len(a.startedDrivers))
|
||||
copy(res, a.startedDrivers)
|
||||
return res
|
||||
}
|
||||
|
||||
// ExecuteCLI parses CLI arguments to configure the profile and runs the application.
|
||||
func (a *App) ExecuteCLI(args ...string) error {
|
||||
var ctx context.Context
|
||||
if a.ctx != nil {
|
||||
ctx = a.ctx.GoContext()
|
||||
} else {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return a.ExecuteCLIWithContext(ctx, args...)
|
||||
}
|
||||
|
||||
// ExecuteCLIWithContext parses CLI arguments, configures the profile, and runs the application with the given context.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (a *App) ExecuteCLIWithContext(ctx context.Context, args ...string) error {
|
||||
cliArgs := args
|
||||
if len(cliArgs) == 0 {
|
||||
cliArgs = os.Args[1:]
|
||||
}
|
||||
|
||||
profile := ProfileAll
|
||||
if len(cliArgs) > 0 {
|
||||
first := strings.TrimSpace(cliArgs[0])
|
||||
switch {
|
||||
case strings.HasPrefix(first, "--profile="):
|
||||
profile = Profile(strings.TrimPrefix(first, "--profile="))
|
||||
case strings.HasPrefix(first, "-p="):
|
||||
profile = Profile(strings.TrimPrefix(first, "-p="))
|
||||
case !strings.HasPrefix(first, "-"):
|
||||
profile = Profile(first)
|
||||
}
|
||||
}
|
||||
|
||||
a.WithProfile(profile)
|
||||
return a.Run(ctx)
|
||||
}
|
||||
|
||||
func matchesProfile(profile Profile, dt DriverType) bool {
|
||||
norm := normalizeProfile(profile)
|
||||
switch norm {
|
||||
case ProfileAll, "":
|
||||
return true
|
||||
case ProfileAPI:
|
||||
return dt == DriverTypeHTTP
|
||||
case ProfileWorker:
|
||||
return dt == DriverTypeWorker
|
||||
case ProfileSchedule:
|
||||
return dt == DriverTypeScheduler
|
||||
default:
|
||||
return string(norm) == string(dt)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProfile(p Profile) Profile {
|
||||
switch strings.ToLower(strings.TrimSpace(string(p))) {
|
||||
case "api", "http":
|
||||
return ProfileAPI
|
||||
case "worker":
|
||||
return ProfileWorker
|
||||
case "schedule", "scheduler", "cron":
|
||||
return ProfileSchedule
|
||||
case "all", "fused", "full", "":
|
||||
return ProfileAll
|
||||
default:
|
||||
return p
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/backend/core"
|
||||
"github.com/Rain-kl/Wavelet/backend/core/extpoints"
|
||||
)
|
||||
|
||||
// appMockDriver is a test driver tracking its start/stop lifecycle.
|
||||
type appMockDriver struct {
|
||||
mu sync.Mutex
|
||||
driverType core.DriverType
|
||||
startCalled bool
|
||||
stopCalled bool
|
||||
startErr error
|
||||
stopErr error
|
||||
}
|
||||
|
||||
func newAppMockDriver(dt core.DriverType) *appMockDriver {
|
||||
return &appMockDriver{driverType: dt}
|
||||
}
|
||||
|
||||
func (m *appMockDriver) Type() core.DriverType {
|
||||
return m.driverType
|
||||
}
|
||||
|
||||
func (m *appMockDriver) Start(_ context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.startErr != nil {
|
||||
return m.startErr
|
||||
}
|
||||
m.startCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *appMockDriver) Stop(_ context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.stopErr != nil {
|
||||
return m.stopErr
|
||||
}
|
||||
m.stopCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *appMockDriver) isStarted() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.startCalled
|
||||
}
|
||||
|
||||
func (m *appMockDriver) isStopped() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.stopCalled
|
||||
}
|
||||
|
||||
// appMockPlugin is a test plugin.
|
||||
type appMockPlugin struct {
|
||||
name string
|
||||
applyFn func(ctx *core.Context) error
|
||||
}
|
||||
|
||||
func (p *appMockPlugin) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *appMockPlugin) Apply(ctx *core.Context) error {
|
||||
if p.applyFn != nil {
|
||||
return p.applyFn(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAppNewAndConfiguration(t *testing.T) {
|
||||
customCtx := core.NewContext(context.Background())
|
||||
p1 := &appMockPlugin{name: "plugin1"}
|
||||
p2 := &appMockPlugin{name: "plugin2"}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithContext(customCtx),
|
||||
core.WithProfile(core.ProfileAPI),
|
||||
core.WithPlugins(p1, p2),
|
||||
core.WithShutdownTimeout(5*time.Second),
|
||||
)
|
||||
|
||||
assert.Equal(t, customCtx, app.Context())
|
||||
assert.Equal(t, core.ProfileAPI, app.Profile())
|
||||
assert.Len(t, app.Plugins(), 2)
|
||||
|
||||
retrieved, ok := app.Plugin("plugin1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, p1, retrieved)
|
||||
|
||||
_, ok = app.Plugin("non_existent")
|
||||
assert.False(t, ok)
|
||||
|
||||
// Update existing plugin in-place
|
||||
p1Updated := &appMockPlugin{name: "plugin1"}
|
||||
app.Use(p1Updated, nil)
|
||||
assert.Len(t, app.Plugins(), 2)
|
||||
retrieved, ok = app.Plugin("plugin1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, p1Updated, retrieved)
|
||||
|
||||
// Test SetProfile
|
||||
app.SetProfile(core.ProfileWorker)
|
||||
assert.Equal(t, core.ProfileWorker, app.Profile())
|
||||
}
|
||||
|
||||
func TestAppProfileDispatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profile core.Profile
|
||||
expectedHTTP bool
|
||||
expectedWorker bool
|
||||
expectedCron bool
|
||||
expectedCustom bool
|
||||
}{
|
||||
{
|
||||
name: "ProfileAPI only starts HTTP driver",
|
||||
profile: core.ProfileAPI,
|
||||
expectedHTTP: true,
|
||||
expectedWorker: false,
|
||||
expectedCron: false,
|
||||
expectedCustom: false,
|
||||
},
|
||||
{
|
||||
name: "ProfileWorker only starts Worker driver",
|
||||
profile: core.ProfileWorker,
|
||||
expectedHTTP: false,
|
||||
expectedWorker: true,
|
||||
expectedCron: false,
|
||||
expectedCustom: false,
|
||||
},
|
||||
{
|
||||
name: "ProfileSchedule only starts Schedule driver",
|
||||
profile: core.ProfileSchedule,
|
||||
expectedHTTP: false,
|
||||
expectedWorker: false,
|
||||
expectedCron: true,
|
||||
expectedCustom: false,
|
||||
},
|
||||
{
|
||||
name: "Profile 'scheduler' alias starts Schedule driver",
|
||||
profile: core.Profile("scheduler"),
|
||||
expectedHTTP: false,
|
||||
expectedWorker: false,
|
||||
expectedCron: true,
|
||||
expectedCustom: false,
|
||||
},
|
||||
{
|
||||
name: "ProfileAll starts all drivers",
|
||||
profile: core.ProfileAll,
|
||||
expectedHTTP: true,
|
||||
expectedWorker: true,
|
||||
expectedCron: true,
|
||||
expectedCustom: true,
|
||||
},
|
||||
{
|
||||
name: "Custom profile starts custom driver",
|
||||
profile: core.Profile("custom_rpc"),
|
||||
expectedHTTP: false,
|
||||
expectedWorker: false,
|
||||
expectedCron: false,
|
||||
expectedCustom: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
httpD := newAppMockDriver(core.DriverTypeHTTP)
|
||||
workerD := newAppMockDriver(core.DriverTypeWorker)
|
||||
cronD := newAppMockDriver(core.DriverTypeScheduler)
|
||||
customD := newAppMockDriver(core.DriverType("custom_rpc"))
|
||||
|
||||
p := &appMockPlugin{
|
||||
name: "drivers_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
_ = ctx.RegisterDriver(httpD)
|
||||
_ = ctx.RegisterDriver(workerD)
|
||||
_ = ctx.RegisterDriver(cronD)
|
||||
_ = ctx.RegisterDriver(customD)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(tt.profile),
|
||||
core.WithPlugins(p),
|
||||
)
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expectedHTTP, httpD.isStarted(), "HTTP driver start mismatch")
|
||||
assert.Equal(t, tt.expectedWorker, workerD.isStarted(), "Worker driver start mismatch")
|
||||
assert.Equal(t, tt.expectedCron, cronD.isStarted(), "Cron driver start mismatch")
|
||||
assert.Equal(t, tt.expectedCustom, customD.isStarted(), "Custom driver start mismatch")
|
||||
|
||||
err = app.Stop(context.Background())
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLifecycleStartStop(t *testing.T) {
|
||||
var stopOrder []string
|
||||
var stopOrderMu sync.Mutex
|
||||
|
||||
httpD := newAppMockDriver(core.DriverTypeHTTP)
|
||||
workerD := newAppMockDriver(core.DriverTypeWorker)
|
||||
|
||||
httpD.stopErr = nil
|
||||
workerD.stopErr = nil
|
||||
|
||||
// Wrap stop to record order
|
||||
origHttpStop := httpD.Stop
|
||||
_ = origHttpStop
|
||||
|
||||
p := &appMockPlugin{
|
||||
name: "test_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
_ = ctx.RegisterDriver(httpD)
|
||||
_ = ctx.RegisterDriver(workerD)
|
||||
|
||||
ctx.OnDispose(func() error {
|
||||
stopOrderMu.Lock()
|
||||
stopOrder = append(stopOrder, "ctx_disposer")
|
||||
stopOrderMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAll),
|
||||
core.WithPlugins(p),
|
||||
)
|
||||
|
||||
var readyReceived, stoppingReceived, stoppedReceived bool
|
||||
app.Context().Events().On("app:ready", func() {
|
||||
readyReceived = true
|
||||
})
|
||||
app.Context().Events().On("app:stopping", func() {
|
||||
stoppingReceived = true
|
||||
})
|
||||
app.Context().Events().On("app:stopped", func() {
|
||||
stoppedReceived = true
|
||||
})
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, app.IsRunning())
|
||||
assert.Len(t, app.StartedDrivers(), 2)
|
||||
assert.True(t, readyReceived)
|
||||
|
||||
err = app.Stop(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, app.IsRunning())
|
||||
assert.Empty(t, app.StartedDrivers())
|
||||
assert.True(t, stoppingReceived)
|
||||
assert.True(t, stoppedReceived)
|
||||
|
||||
assert.True(t, httpD.isStopped())
|
||||
assert.True(t, workerD.isStopped())
|
||||
assert.True(t, app.Context().IsDisposed())
|
||||
|
||||
stopOrderMu.Lock()
|
||||
assert.Contains(t, stopOrder, "ctx_disposer")
|
||||
stopOrderMu.Unlock()
|
||||
}
|
||||
|
||||
func TestAppStartDriverFailureRollback(t *testing.T) {
|
||||
driver1 := newAppMockDriver(core.DriverTypeHTTP)
|
||||
driver2 := newAppMockDriver(core.DriverTypeWorker)
|
||||
driver2.startErr = errors.New("worker listen port conflict")
|
||||
driver3 := newAppMockDriver(core.DriverTypeScheduler)
|
||||
|
||||
p := &appMockPlugin{
|
||||
name: "fail_driver_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
_ = ctx.RegisterDriver(driver1)
|
||||
_ = ctx.RegisterDriver(driver2)
|
||||
_ = ctx.RegisterDriver(driver3)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAll),
|
||||
core.WithPlugins(p),
|
||||
)
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "worker listen port conflict")
|
||||
assert.False(t, app.IsRunning())
|
||||
|
||||
// Driver 1 was started then rolled back (stopped)
|
||||
assert.True(t, driver1.isStarted())
|
||||
assert.True(t, driver1.isStopped())
|
||||
|
||||
// Driver 3 was never started
|
||||
assert.False(t, driver3.isStarted())
|
||||
}
|
||||
|
||||
func TestAppMigrationEngineExecution(t *testing.T) {
|
||||
var migratedEntries []extpoints.MigrationEntry
|
||||
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
|
||||
migratedEntries = entries
|
||||
return nil
|
||||
})
|
||||
|
||||
sqlFS := fstest.MapFS{
|
||||
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE users(id int);")},
|
||||
}
|
||||
|
||||
p := &appMockPlugin{
|
||||
name: "auth",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
ctx.Migrations().Register("auth", sqlFS)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAll),
|
||||
core.WithPlugins(p),
|
||||
core.WithMigrationRunner(runner),
|
||||
)
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = app.Stop(context.Background()) }()
|
||||
|
||||
require.Len(t, migratedEntries, 1)
|
||||
assert.Equal(t, "auth", migratedEntries[0].PluginID)
|
||||
}
|
||||
|
||||
func TestAppMigrationEngineFromIoCContainer(t *testing.T) {
|
||||
var executed bool
|
||||
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
|
||||
executed = true
|
||||
return nil
|
||||
})
|
||||
|
||||
sqlFS := fstest.MapFS{
|
||||
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE logs(id int);")},
|
||||
}
|
||||
|
||||
p := &appMockPlugin{
|
||||
name: "logstore",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
ctx.Migrations().Register("logstore", sqlFS)
|
||||
core.Provide[core.MigrationEngine](ctx, runner)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAll),
|
||||
core.WithPlugins(p),
|
||||
)
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = app.Stop(context.Background()) }()
|
||||
|
||||
assert.True(t, executed)
|
||||
}
|
||||
|
||||
func TestAppRunContextCancellation(t *testing.T) {
|
||||
d := newAppMockDriver(core.DriverTypeHTTP)
|
||||
p := &appMockPlugin{
|
||||
name: "http_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
return ctx.RegisterDriver(d)
|
||||
},
|
||||
}
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAPI),
|
||||
core.WithPlugins(p),
|
||||
core.WithShutdownTimeout(1*time.Second),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- app.Run(ctx)
|
||||
}()
|
||||
|
||||
// Wait briefly then cancel
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.True(t, app.IsRunning())
|
||||
assert.True(t, d.isStarted())
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, app.IsRunning())
|
||||
assert.True(t, d.isStopped())
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("app.Run did not terminate upon context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppExecuteCLI(t *testing.T) {
|
||||
// Test CLI argument parsing logic
|
||||
tests := []struct {
|
||||
args []string
|
||||
expectedProfile core.Profile
|
||||
}{
|
||||
{args: []string{"api"}, expectedProfile: core.ProfileAPI},
|
||||
{args: []string{"worker"}, expectedProfile: core.ProfileWorker},
|
||||
{args: []string{"scheduler"}, expectedProfile: core.ProfileSchedule},
|
||||
{args: []string{"schedule"}, expectedProfile: core.ProfileSchedule},
|
||||
{args: []string{"all"}, expectedProfile: core.ProfileAll},
|
||||
{args: []string{"--profile=worker"}, expectedProfile: core.ProfileWorker},
|
||||
{args: []string{"-p=api"}, expectedProfile: core.ProfileAPI},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.args[0], func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
// Use custom root context to control cancellation
|
||||
customApp := core.NewApp(core.WithContext(core.NewContext(ctx)))
|
||||
_ = customApp.ExecuteCLI(tt.args...)
|
||||
assert.Equal(t, tt.expectedProfile, customApp.Profile())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppIdempotencyAndErrorStates(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
|
||||
// Double start returns error
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = app.Start(context.Background())
|
||||
assert.ErrorIs(t, err, core.ErrAppRunning)
|
||||
|
||||
// Stop clears running state
|
||||
err = app.Stop(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Double stop succeeds
|
||||
err = app.Stop(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Plugin apply failure
|
||||
failPlugin := &appMockPlugin{
|
||||
name: "failing_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
return errors.New("plugin init boom")
|
||||
},
|
||||
}
|
||||
app2 := core.NewApp(core.WithPlugins(failPlugin))
|
||||
err = app2.Start(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "plugin init boom")
|
||||
assert.False(t, app2.IsRunning())
|
||||
|
||||
// Migration failure
|
||||
migFailRunner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
|
||||
return errors.New("sql migrate error")
|
||||
})
|
||||
sqlFS := fstest.MapFS{
|
||||
"migrations/001.sql": &fstest.MapFile{Data: []byte("...")},
|
||||
}
|
||||
migPlugin := &appMockPlugin{
|
||||
name: "db_plugin",
|
||||
applyFn: func(ctx *core.Context) error {
|
||||
ctx.Migrations().Register("db_plugin", sqlFS)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
app3 := core.NewApp(
|
||||
core.WithPlugins(migPlugin),
|
||||
core.WithMigrationRunner(migFailRunner),
|
||||
)
|
||||
err = app3.Start(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "sql migrate error")
|
||||
assert.False(t, app3.IsRunning())
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package core provides the micro-kernel service bus, generic IoC container, and runtime extensions.
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Container manages service registration and resolution using Go reflection and generics.
|
||||
type Container struct {
|
||||
mu sync.RWMutex
|
||||
parent *Container
|
||||
services map[reflect.Type]any
|
||||
listeners map[reflect.Type][]func(any)
|
||||
}
|
||||
|
||||
// NewContainer creates a new IoC container instance with an optional parent container.
|
||||
func NewContainer(parent *Container) *Container {
|
||||
return &Container{
|
||||
parent: parent,
|
||||
services: make(map[reflect.Type]any),
|
||||
listeners: make(map[reflect.Type][]func(any)),
|
||||
}
|
||||
}
|
||||
|
||||
func isNil(i any) bool {
|
||||
if i == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(i)
|
||||
switch v.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer, reflect.Interface, reflect.Slice:
|
||||
return v.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Provide registers a typed service implementation into the Context's IoC container.
|
||||
func Provide[T any](ctx *Context, service T) {
|
||||
if ctx == nil {
|
||||
panic("core: nil context provided to Provide")
|
||||
}
|
||||
if isNil(service) {
|
||||
panic("core: cannot provide nil service")
|
||||
}
|
||||
|
||||
targetType := reflect.TypeFor[T]()
|
||||
ctx.Container().provide(targetType, service)
|
||||
}
|
||||
|
||||
func (c *Container) provide(targetType reflect.Type, service any) {
|
||||
c.mu.Lock()
|
||||
c.services[targetType] = service
|
||||
|
||||
// Collect any matching listeners to invoke outside the lock
|
||||
var callbacks []func(any)
|
||||
svcType := reflect.TypeOf(service)
|
||||
for lType, cbs := range c.listeners {
|
||||
if lType == targetType || (lType.Kind() == reflect.Interface && svcType.Implements(lType)) {
|
||||
callbacks = append(callbacks, cbs...)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, cb := range callbacks {
|
||||
cb(service)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject resolves a registered service of type T from the Context.
|
||||
func Inject[T any](ctx *Context) (T, error) {
|
||||
var zero T
|
||||
if ctx == nil {
|
||||
return zero, ErrNilContext
|
||||
}
|
||||
|
||||
targetType := reflect.TypeFor[T]()
|
||||
val, err := ctx.Container().resolve(targetType)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
typedVal, ok := val.(T)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("%w: cannot cast %T to %v", ErrServiceNotFound, val, targetType)
|
||||
}
|
||||
return typedVal, nil
|
||||
}
|
||||
|
||||
func (c *Container) resolve(targetType reflect.Type) (any, error) {
|
||||
c.mu.RLock()
|
||||
// 1. Direct type match
|
||||
if val, ok := c.services[targetType]; ok {
|
||||
c.mu.RUnlock()
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// 2. Interface assignment scan
|
||||
if targetType.Kind() == reflect.Interface {
|
||||
for _, val := range c.services {
|
||||
if reflect.TypeOf(val).Implements(targetType) {
|
||||
c.mu.RUnlock()
|
||||
return val, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// 3. Fallback to parent container
|
||||
if c.parent != nil {
|
||||
return c.parent.resolve(targetType)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: %v", ErrServiceNotFound, targetType)
|
||||
}
|
||||
|
||||
// MustInject resolves a service of type T or panics if the service is not found.
|
||||
func MustInject[T any](ctx *Context) T {
|
||||
s, err := Inject[T](ctx)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("core: failed to inject service %v: %v", reflect.TypeFor[T](), err))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Has returns true if a service of type T is registered and resolvable in the Context.
|
||||
func Has[T any](ctx *Context) bool {
|
||||
_, err := Inject[T](ctx)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Using executes the given function synchronously if the required dependency is ready.
|
||||
func Using[T1 any](ctx *Context, fn func(s1 T1)) error {
|
||||
s1, err := Inject[T1](ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
|
||||
}
|
||||
fn(s1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Using2 executes the given function synchronously if both required dependencies are ready.
|
||||
func Using2[T1, T2 any](ctx *Context, fn func(s1 T1, s2 T2)) error {
|
||||
s1, err1 := Inject[T1](ctx)
|
||||
s2, err2 := Inject[T2](ctx)
|
||||
if err1 != nil || err2 != nil {
|
||||
return fmt.Errorf("%w: (dep1: %v, dep2: %v)", ErrServiceNotReady, err1, err2)
|
||||
}
|
||||
fn(s1, s2)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Using3 executes the given function synchronously if all 3 required dependencies are ready.
|
||||
func Using3[T1, T2, T3 any](ctx *Context, fn func(s1 T1, s2 T2, s3 T3)) error {
|
||||
s1, err1 := Inject[T1](ctx)
|
||||
s2, err2 := Inject[T2](ctx)
|
||||
s3, err3 := Inject[T3](ctx)
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return fmt.Errorf("%w: (dep1: %v, dep2: %v, dep3: %v)", ErrServiceNotReady, err1, err2, err3)
|
||||
}
|
||||
fn(s1, s2, s3)
|
||||
return nil
|
||||
}
|
||||
|
||||
// When registers a reactive hook that is called immediately if T is already provided,
|
||||
// or called as soon as T is provided in the future.
|
||||
func When[T any](ctx *Context, fn func(s T)) {
|
||||
if ctx == nil {
|
||||
panic("core: nil context provided to When")
|
||||
}
|
||||
|
||||
targetType := reflect.TypeFor[T]()
|
||||
c := ctx.Container()
|
||||
|
||||
// If already ready, execute immediately
|
||||
if s, err := Inject[T](ctx); err == nil {
|
||||
fn(s)
|
||||
}
|
||||
|
||||
// Also register listener for future calls / updates
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.listeners[targetType] = append(c.listeners[targetType], func(val any) {
|
||||
if typed, ok := val.(T); ok {
|
||||
fn(typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/backend/core/extpoints"
|
||||
)
|
||||
|
||||
// Context is the central micro-kernel service bus and runtime lifecycle container.
|
||||
// It embeds Go standard context.Context compatibility, hierarchical scoping,
|
||||
// service resolution, and LIFO disposer teardown.
|
||||
type Context struct {
|
||||
goCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
parent *Context
|
||||
container *Container
|
||||
|
||||
events *EventBus
|
||||
router extpoints.RouterExtension
|
||||
migrations extpoints.MigrationExtension
|
||||
tasks extpoints.TaskExtension
|
||||
schedules extpoints.ScheduleExtension
|
||||
settings extpoints.SettingExtension
|
||||
|
||||
mu sync.RWMutex
|
||||
children []*Context
|
||||
disposers []Disposer
|
||||
drivers []Driver
|
||||
values map[any]any
|
||||
disposed bool
|
||||
}
|
||||
|
||||
// NewContext creates a new root Context wrapping a standard Go context.
|
||||
// If base is nil, context.Background() is used by default.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func NewContext(base context.Context) *Context {
|
||||
if base == nil {
|
||||
base = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(base)
|
||||
|
||||
return &Context{
|
||||
goCtx: ctx,
|
||||
cancel: cancel,
|
||||
container: NewContainer(nil),
|
||||
events: NewEventBus(),
|
||||
router: extpoints.NewRouterRegistry(),
|
||||
migrations: extpoints.NewMigrationRegistry(),
|
||||
tasks: extpoints.NewTaskRegistry(),
|
||||
schedules: extpoints.NewScheduleRegistry(),
|
||||
settings: extpoints.NewSettingRegistry(),
|
||||
values: make(map[any]any),
|
||||
}
|
||||
}
|
||||
|
||||
// Deadline returns the time when work done on behalf of this context should be canceled.
|
||||
func (c *Context) Deadline() (deadline time.Time, ok bool) {
|
||||
return c.goCtx.Deadline()
|
||||
}
|
||||
|
||||
// Done returns a channel that's closed when work done on behalf of this context should be canceled.
|
||||
func (c *Context) Done() <-chan struct{} {
|
||||
return c.goCtx.Done()
|
||||
}
|
||||
|
||||
// Err returns a non-nil error value after Done is closed.
|
||||
func (c *Context) Err() error {
|
||||
return c.goCtx.Err()
|
||||
}
|
||||
|
||||
// Value returns the value associated with key, searching the local values map,
|
||||
// the underlying Go context, and fallback parent Contexts.
|
||||
func (c *Context) Value(key any) any {
|
||||
c.mu.RLock()
|
||||
if v, ok := c.values[key]; ok {
|
||||
c.mu.RUnlock()
|
||||
return v
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
if v := c.goCtx.Value(key); v != nil {
|
||||
return v
|
||||
}
|
||||
|
||||
if c.parent != nil {
|
||||
return c.parent.Value(key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GoContext returns the underlying standard Go context.Context.
|
||||
func (c *Context) GoContext() context.Context {
|
||||
return c.goCtx
|
||||
}
|
||||
|
||||
// Set stores an arbitrary key-value pair in this Context's local storage.
|
||||
func (c *Context) Set(key any, val any) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.values == nil {
|
||||
c.values = make(map[any]any)
|
||||
}
|
||||
c.values[key] = val
|
||||
}
|
||||
|
||||
// Get retrieves a key-value pair from this Context's local storage.
|
||||
func (c *Context) Get(key any) (any, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.values == nil {
|
||||
return nil, false
|
||||
}
|
||||
v, ok := c.values[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Container returns the underlying IoC container for this Context.
|
||||
func (c *Context) Container() *Container {
|
||||
return c.container
|
||||
}
|
||||
|
||||
// Parent returns the parent Context, or nil if this is a root Context.
|
||||
func (c *Context) Parent() *Context {
|
||||
return c.parent
|
||||
}
|
||||
|
||||
// Fork creates a child Context with its own scoped IoC container and values,
|
||||
// linked to this Context for hierarchical fallback resolution and cascading teardown.
|
||||
func (c *Context) Fork() *Context {
|
||||
return c.ForkWithContext(c.goCtx)
|
||||
}
|
||||
|
||||
// ForkWithContext creates a child Context using a specific standard Go context.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (c *Context) ForkWithContext(base context.Context) *Context {
|
||||
if base == nil {
|
||||
base = c.goCtx
|
||||
}
|
||||
ctx, cancel := context.WithCancel(base)
|
||||
|
||||
child := &Context{
|
||||
goCtx: ctx,
|
||||
cancel: cancel,
|
||||
parent: c,
|
||||
container: NewContainer(c.container),
|
||||
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.children = append(c.children, child)
|
||||
c.mu.Unlock()
|
||||
|
||||
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.
|
||||
// It accepts func() error, func(), or Disposer.
|
||||
func (c *Context) OnDispose(fn any) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var d Disposer
|
||||
switch f := fn.(type) {
|
||||
case Disposer:
|
||||
d = f
|
||||
case func() error:
|
||||
d = f
|
||||
case func():
|
||||
d = func() error {
|
||||
f()
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("core: OnDispose expects func() error or func(), got %T", fn))
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.disposers = append(c.disposers, d)
|
||||
}
|
||||
|
||||
// Dispose shuts down this Context and all child Contexts, running registered disposers in LIFO order.
|
||||
func (c *Context) Dispose() error {
|
||||
c.mu.Lock()
|
||||
if c.disposed {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.disposed = true
|
||||
|
||||
// Copy children and disposers under lock
|
||||
children := make([]*Context, len(c.children))
|
||||
copy(children, c.children)
|
||||
|
||||
disposers := make([]Disposer, len(c.disposers))
|
||||
copy(disposers, c.disposers)
|
||||
c.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
|
||||
// 1. Dispose all child contexts in reverse order
|
||||
for i := len(children) - 1; i >= 0; i-- {
|
||||
if err := children[i].Dispose(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Run local disposers in LIFO order
|
||||
for i := len(disposers) - 1; i >= 0; i-- {
|
||||
if err := disposers[i](); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cancel the Go context
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
// 4. Detach from parent
|
||||
if c.parent != nil {
|
||||
c.parent.removeChild(c)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Context) removeChild(target *Context) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for i, child := range c.children {
|
||||
if child == target {
|
||||
c.children = append(c.children[:i], c.children[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsDisposed returns true if this Context has been disposed.
|
||||
func (c *Context) IsDisposed() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.disposed
|
||||
}
|
||||
|
||||
// RegisterDriver registers a runtime driver engine on this Context.
|
||||
func (c *Context) RegisterDriver(d Driver) error {
|
||||
if d == nil {
|
||||
return ErrNilService
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.drivers = append(c.drivers, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Drivers returns a copy of all drivers registered on this Context.
|
||||
func (c *Context) Drivers() []Driver {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
result := make([]Driver, len(c.drivers))
|
||||
copy(result, c.drivers)
|
||||
return result
|
||||
}
|
||||
|
||||
// Driver looks up a registered driver by its driver type.
|
||||
func (c *Context) Driver(driverType DriverType) (Driver, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
for _, d := range c.drivers {
|
||||
if d.Type() == driverType {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/backend/core"
|
||||
)
|
||||
|
||||
// Sample services for testing
|
||||
type SampleService interface {
|
||||
Greet(name string) string
|
||||
}
|
||||
|
||||
type sampleServiceImpl struct {
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (s *sampleServiceImpl) Greet(name string) string {
|
||||
if s.prefix != "" {
|
||||
return s.prefix + " " + name
|
||||
}
|
||||
return "Hello, " + name
|
||||
}
|
||||
|
||||
type LogService interface {
|
||||
Log(msg string)
|
||||
}
|
||||
|
||||
type logServiceImpl struct {
|
||||
logs []string
|
||||
}
|
||||
|
||||
func (l *logServiceImpl) Log(msg string) {
|
||||
l.logs = append(l.logs, msg)
|
||||
}
|
||||
|
||||
type ConfigService interface {
|
||||
Get(key string) string
|
||||
}
|
||||
|
||||
type configServiceImpl struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (c *configServiceImpl) Get(key string) string {
|
||||
return c.data[key]
|
||||
}
|
||||
|
||||
// Sample plugin for testing
|
||||
type samplePlugin struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (p *samplePlugin) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *samplePlugin) Apply(ctx *core.Context) error {
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{prefix: "Plugin:"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *samplePlugin) Manifest() core.Manifest {
|
||||
return core.Manifest{
|
||||
Name: p.name,
|
||||
Version: "1.0.0",
|
||||
Description: "Sample plugin",
|
||||
}
|
||||
}
|
||||
|
||||
// Sample driver for testing
|
||||
type mockDriver struct {
|
||||
driverType core.DriverType
|
||||
started bool
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func (m *mockDriver) Type() core.DriverType {
|
||||
return m.driverType
|
||||
}
|
||||
|
||||
func (m *mockDriver) Start(ctx context.Context) error {
|
||||
m.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDriver) Stop(ctx context.Context) error {
|
||||
m.stopped = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestContextProvideAndInject(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
// Before providing, Inject should fail
|
||||
_, err := core.Inject[SampleService](ctx)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, core.ErrServiceNotFound))
|
||||
assert.False(t, core.Has[SampleService](ctx))
|
||||
|
||||
// MustInject should panic
|
||||
assert.Panics(t, func() {
|
||||
core.MustInject[SampleService](ctx)
|
||||
})
|
||||
|
||||
// Provide service
|
||||
svcImpl := &sampleServiceImpl{prefix: "Hello,"}
|
||||
core.Provide[SampleService](ctx, svcImpl)
|
||||
|
||||
// Inject should succeed
|
||||
assert.True(t, core.Has[SampleService](ctx))
|
||||
svc, err := core.Inject[SampleService](ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Hello, Wavelet", svc.Greet("Wavelet"))
|
||||
|
||||
// MustInject should succeed
|
||||
mustSvc := core.MustInject[SampleService](ctx)
|
||||
assert.Equal(t, "Hello, Cordis", mustSvc.Greet("Cordis"))
|
||||
}
|
||||
|
||||
func TestContextProvideNilPanics(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
assert.Panics(t, func() {
|
||||
core.Provide[SampleService](nil, &sampleServiceImpl{})
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
var nilSvc SampleService
|
||||
core.Provide[SampleService](ctx, nilSvc)
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
var nilImpl *sampleServiceImpl
|
||||
core.Provide[*sampleServiceImpl](ctx, nilImpl)
|
||||
})
|
||||
|
||||
// Inject with nil context
|
||||
var nilCtx *core.Context
|
||||
_, err := core.Inject[SampleService](nilCtx)
|
||||
assert.ErrorIs(t, err, core.ErrNilContext)
|
||||
}
|
||||
|
||||
func TestContextUsing(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
var called bool
|
||||
|
||||
// Using when service not ready should return ErrServiceNotReady
|
||||
err := core.Using(ctx, func(s SampleService) {
|
||||
called = true
|
||||
assert.Equal(t, "Hello, Cordis", s.Greet("Cordis"))
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, core.ErrServiceNotReady))
|
||||
assert.False(t, called)
|
||||
|
||||
// Provide service and try Using again
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
err = core.Using(ctx, func(s SampleService) {
|
||||
called = true
|
||||
assert.Equal(t, "Hello, Cordis", s.Greet("Cordis"))
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestContextUsingMultiple(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
// Using2 with missing dependencies
|
||||
var called2 bool
|
||||
err := core.Using2(ctx, func(s SampleService, l LogService) {
|
||||
called2 = true
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.False(t, called2)
|
||||
|
||||
// Provide 1 of 2
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
err = core.Using2(ctx, func(s SampleService, l LogService) {
|
||||
called2 = true
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.False(t, called2)
|
||||
|
||||
// Provide 2 of 2
|
||||
logSvc := &logServiceImpl{}
|
||||
core.Provide[LogService](ctx, logSvc)
|
||||
err = core.Using2(ctx, func(s SampleService, l LogService) {
|
||||
called2 = true
|
||||
l.Log(s.Greet("World"))
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called2)
|
||||
assert.Equal(t, []string{"Hello, World"}, logSvc.logs)
|
||||
|
||||
// Using3 test - error condition
|
||||
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {})
|
||||
assert.Error(t, err)
|
||||
|
||||
// Using3 test - success condition
|
||||
var called3 bool
|
||||
cfgSvc := &configServiceImpl{data: map[string]string{"env": "test"}}
|
||||
core.Provide[ConfigService](ctx, cfgSvc)
|
||||
|
||||
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {
|
||||
called3 = true
|
||||
assert.Equal(t, "test", c.Get("env"))
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called3)
|
||||
}
|
||||
|
||||
func TestContextHierarchyAndFork(t *testing.T) {
|
||||
parent := core.NewContext(nil) // nil base context test
|
||||
core.Provide[SampleService](parent, &sampleServiceImpl{prefix: "Parent:"})
|
||||
|
||||
child := parent.ForkWithContext(nil) // nil child context test
|
||||
require.NotNil(t, child)
|
||||
assert.Equal(t, parent, child.Parent())
|
||||
|
||||
// Child can resolve service from parent
|
||||
svc, err := core.Inject[SampleService](child)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Parent: Ryan", svc.Greet("Ryan"))
|
||||
|
||||
// Child provides LogService
|
||||
childLog := &logServiceImpl{}
|
||||
core.Provide[LogService](child, childLog)
|
||||
|
||||
// Child has LogService, parent does not
|
||||
assert.True(t, core.Has[LogService](child))
|
||||
assert.False(t, core.Has[LogService](parent))
|
||||
|
||||
// Child overrides SampleService
|
||||
core.Provide[SampleService](child, &sampleServiceImpl{prefix: "Child:"})
|
||||
childSvc, err := core.Inject[SampleService](child)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Child: Ryan", childSvc.Greet("Ryan"))
|
||||
|
||||
parentSvc, err := core.Inject[SampleService](parent)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Parent: Ryan", parentSvc.Greet("Ryan"))
|
||||
}
|
||||
|
||||
func TestContextReactiveWhen(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
assert.Panics(t, func() {
|
||||
core.When[SampleService](nil, func(s SampleService) {})
|
||||
})
|
||||
|
||||
var whenCalled atomic.Bool
|
||||
var greeted string
|
||||
|
||||
// Register When before service is provided
|
||||
core.When[SampleService](ctx, func(s SampleService) {
|
||||
whenCalled.Store(true)
|
||||
greeted = s.Greet("Reactive")
|
||||
})
|
||||
|
||||
assert.False(t, whenCalled.Load())
|
||||
|
||||
// Now Provide the service - listener should trigger
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
|
||||
assert.True(t, whenCalled.Load())
|
||||
assert.Equal(t, "Hello, Reactive", greeted)
|
||||
|
||||
// Register another When after service is already provided - should trigger immediately
|
||||
var immediateCalled bool
|
||||
core.When[SampleService](ctx, func(s SampleService) {
|
||||
immediateCalled = true
|
||||
})
|
||||
assert.True(t, immediateCalled)
|
||||
}
|
||||
|
||||
func TestContextDisposerLifecycle(t *testing.T) {
|
||||
parent := core.NewContext(context.Background())
|
||||
child := parent.Fork()
|
||||
|
||||
var order []string
|
||||
|
||||
// Test nil disposer
|
||||
parent.OnDispose(nil)
|
||||
|
||||
// Test Disposer type
|
||||
var customDisposer core.Disposer = func() error {
|
||||
order = append(order, "parent-custom")
|
||||
return nil
|
||||
}
|
||||
parent.OnDispose(customDisposer)
|
||||
|
||||
parent.OnDispose(func() error {
|
||||
order = append(order, "parent-1")
|
||||
return nil
|
||||
})
|
||||
parent.OnDispose(func() {
|
||||
order = append(order, "parent-2")
|
||||
})
|
||||
|
||||
child.OnDispose(func() error {
|
||||
order = append(order, "child-1")
|
||||
return errors.New("child-1 error")
|
||||
})
|
||||
child.OnDispose(func() {
|
||||
order = append(order, "child-2")
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
parent.OnDispose("invalid-func")
|
||||
})
|
||||
|
||||
assert.False(t, parent.IsDisposed())
|
||||
assert.False(t, child.IsDisposed())
|
||||
|
||||
// Disposing parent should cascade to children first, and execute disposers in LIFO order
|
||||
err := parent.Dispose()
|
||||
assert.Error(t, err) // child-1 error should be joined
|
||||
assert.Contains(t, err.Error(), "child-1 error")
|
||||
|
||||
assert.True(t, parent.IsDisposed())
|
||||
assert.True(t, child.IsDisposed())
|
||||
|
||||
// Child disposers run in LIFO: child-2, child-1
|
||||
// Parent disposers run in LIFO: parent-2, parent-1, parent-custom
|
||||
expected := []string{"child-2", "child-1", "parent-2", "parent-1", "parent-custom"}
|
||||
assert.Equal(t, expected, order)
|
||||
|
||||
// Disposing again should be idempotent and return nil
|
||||
err = parent.Dispose()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestContextStandardGoContext(t *testing.T) {
|
||||
baseCtx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
|
||||
defer cancel()
|
||||
|
||||
parentCtx := core.NewContext(baseCtx)
|
||||
parentCtx.Set("parent_key", "parent_val")
|
||||
|
||||
childCtx := parentCtx.Fork()
|
||||
|
||||
// Deadline
|
||||
dl, ok := childCtx.Deadline()
|
||||
assert.True(t, ok)
|
||||
assert.False(t, dl.IsZero())
|
||||
|
||||
// Value fallback: child has no key, falls back to parentCtx
|
||||
assert.Equal(t, "parent_val", childCtx.Value("parent_key"))
|
||||
|
||||
// GoContext getter
|
||||
assert.NotNil(t, childCtx.GoContext())
|
||||
|
||||
// Value not found in either
|
||||
assert.Nil(t, childCtx.Value("non_existent_key"))
|
||||
|
||||
// Cancellation propagation
|
||||
select {
|
||||
case <-childCtx.Done():
|
||||
t.Fatal("ctx should not be done yet")
|
||||
default:
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-childCtx.Done():
|
||||
assert.Equal(t, context.Canceled, childCtx.Err())
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("ctx should be cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestValidation(t *testing.T) {
|
||||
mValid := core.Manifest{
|
||||
Name: "auth",
|
||||
Version: "1.0.0",
|
||||
Description: "Auth plugin",
|
||||
}
|
||||
assert.NoError(t, mValid.Validate())
|
||||
|
||||
mInvalid := core.Manifest{
|
||||
Version: "1.0.0",
|
||||
}
|
||||
assert.Error(t, mInvalid.Validate())
|
||||
}
|
||||
|
||||
func TestDriverRegistration(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
// Register nil driver returns error
|
||||
assert.ErrorIs(t, ctx.RegisterDriver(nil), core.ErrNilService)
|
||||
|
||||
dHTTP := &mockDriver{driverType: core.DriverTypeHTTP}
|
||||
dWorker := &mockDriver{driverType: core.DriverTypeWorker}
|
||||
|
||||
require.NoError(t, ctx.RegisterDriver(dHTTP))
|
||||
require.NoError(t, ctx.RegisterDriver(dWorker))
|
||||
|
||||
drivers := ctx.Drivers()
|
||||
assert.Len(t, drivers, 2)
|
||||
|
||||
foundHTTP, ok := ctx.Driver(core.DriverTypeHTTP)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, dHTTP, foundHTTP)
|
||||
|
||||
foundWorker, ok := ctx.Driver(core.DriverTypeWorker)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, dWorker, foundWorker)
|
||||
|
||||
_, ok = ctx.Driver(core.DriverTypeScheduler)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestPluginInterfaces(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
var p core.Plugin = &samplePlugin{name: "sample"}
|
||||
assert.Equal(t, "sample", p.Name())
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
svc, err := core.Inject[SampleService](ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Plugin: Ryan", svc.Greet("Ryan"))
|
||||
|
||||
var pwm core.PluginWithManifest = &samplePlugin{name: "sample"}
|
||||
manifest := pwm.Manifest()
|
||||
assert.Equal(t, "sample", manifest.Name)
|
||||
assert.Equal(t, "1.0.0", manifest.Version)
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrently provide, inject, fork, set, and get
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ctx.Set(fmt.Sprintf("key-%d", idx), idx)
|
||||
_, _ = ctx.Get(fmt.Sprintf("key-%d", idx))
|
||||
|
||||
child := ctx.Fork()
|
||||
child.Set("child_key", idx)
|
||||
}(i)
|
||||
}
|
||||
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
svc, err := core.Inject[SampleService](ctx)
|
||||
if err == nil {
|
||||
_ = svc.Greet("Concurrency")
|
||||
}
|
||||
_ = core.Using(ctx, func(s SampleService) {
|
||||
_ = s.Greet("Safe")
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserDTO represents a unified user data transfer object across plugins.
|
||||
type UserDTO struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
NeedChangePassword bool `json:"need_change_password,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
LastLoginAt time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName returns w_users.
|
||||
func (UserDTO) TableName() string {
|
||||
return "w_users"
|
||||
}
|
||||
|
||||
// OAuthUserInfoDTO contains user identity claims obtained from an OAuth provider.
|
||||
type OAuthUserInfoDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
Sub string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
// OAuthProvider defines the pluggable OAuth provider contract.
|
||||
type OAuthProvider interface {
|
||||
Name() string
|
||||
GetAuthURL(state string) string
|
||||
ExchangeCode(ctx context.Context, code string) (*OAuthUserInfoDTO, error)
|
||||
}
|
||||
|
||||
// AuthService defines the contract for authentication, session verification, and token management.
|
||||
type AuthService interface {
|
||||
// RequireAuthMiddleware returns a middleware handler (compatible with gin.HandlerFunc or standard middleware).
|
||||
RequireAuthMiddleware() any
|
||||
|
||||
// RequireAdminMiddleware returns an admin authorization middleware.
|
||||
RequireAdminMiddleware() any
|
||||
|
||||
// GetCurrentUser retrieves the authenticated UserDTO from context.
|
||||
GetCurrentUser(ctx context.Context) (*UserDTO, error)
|
||||
|
||||
// GetCurrentUserID retrieves the authenticated user ID from session/context.
|
||||
GetCurrentUserID(ctx context.Context) (uint64, error)
|
||||
|
||||
// VerifyToken validates an access token and returns the associated user DTO.
|
||||
VerifyToken(ctx context.Context, token string) (*UserDTO, error)
|
||||
|
||||
// CreateSession establishes an authenticated session for the given user ID.
|
||||
CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error)
|
||||
|
||||
// RevokeToken invalidates a specific access token by its hash.
|
||||
RevokeToken(ctx context.Context, tokenHash string) error
|
||||
|
||||
// RevokeUserSessions revokes all active sessions and cached tokens for a user.
|
||||
RevokeUserSessions(ctx context.Context, userID uint64) error
|
||||
|
||||
// DisallowTokenAuthMiddleware returns a middleware that rejects requests authenticated via access token.
|
||||
DisallowTokenAuthMiddleware() any
|
||||
}
|
||||
|
||||
// AuthRegistry allows downstream and domain plugins to register custom authentication providers.
|
||||
type AuthRegistry interface {
|
||||
RegisterOAuthProvider(name string, provider OAuthProvider)
|
||||
GetOAuthProvider(name string) (OAuthProvider, bool)
|
||||
ListOAuthProviders() []string
|
||||
}
|
||||
|
||||
// Auth context keys — stored in Gin context by auth middleware, consumed by domain plugins.
|
||||
const (
|
||||
AuthUserIDKey = "user_id"
|
||||
AuthUserNameKey = "username"
|
||||
AuthUserObjKey = "user_obj"
|
||||
AuthTokenAuthKey = "token_auth" // marks if request uses access token auth
|
||||
AuthTokenAdminKey = "token_admin" // whether the access token has admin privileges
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrCacheMiss is returned when an item is not found in the cache.
|
||||
var ErrCacheMiss = errors.New("contracts/cache: key not found")
|
||||
|
||||
// CacheService defines the contract for multi-layer cache operations (RAM L1 + Redis L2 + Pub/Sub invalidation).
|
||||
type CacheService interface {
|
||||
// Get retrieves an item from cache into target. Returns ErrCacheMiss if not found.
|
||||
Get(ctx context.Context, key string, target any) error
|
||||
|
||||
// Set stores an item into cache with a specified time-to-live duration.
|
||||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||||
|
||||
// Delete evicts a key from local and remote cache tiers and broadcasts invalidation.
|
||||
Delete(ctx context.Context, key string) error
|
||||
|
||||
// GetOrSet retrieves an item from cache, or calls loader to populate and return if missing.
|
||||
GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error
|
||||
|
||||
// Invalidate is a semantic alias for Delete.
|
||||
Invalidate(ctx context.Context, key string) error
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DBService defines the standard contract for relational database access and multi-datasource routing.
|
||||
type DBService interface {
|
||||
// GORM returns the underlying GORM database instance.
|
||||
GORM() *gorm.DB
|
||||
|
||||
// DB returns the GORM database instance bound to the given context.
|
||||
DB(ctx context.Context) *gorm.DB
|
||||
|
||||
// Named returns a named database connection if multiple data sources or replicas are configured.
|
||||
Named(name string) *gorm.DB
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
// ======================================================================
|
||||
// Domain Event Topic Constants
|
||||
// ======================================================================
|
||||
//
|
||||
// All cross-plugin domain event topics MUST be declared here so that
|
||||
// producers and consumers share the same string values without importing
|
||||
// each other's implementation packages.
|
||||
// ======================================================================
|
||||
|
||||
// --- Auth & User Events ---
|
||||
const (
|
||||
// EventTopicAdminLoggedIn fires when an admin user logs in.
|
||||
EventTopicAdminLoggedIn = "admin:logged_in"
|
||||
|
||||
// EventTopicUserCreated fires when a new user account is created.
|
||||
EventTopicUserCreated = "user:created"
|
||||
|
||||
// EventTopicUserUpdated fires when a user profile is updated.
|
||||
EventTopicUserUpdated = "user:updated"
|
||||
|
||||
// EventTopicUserDeleted fires when a user account is deleted.
|
||||
EventTopicUserDeleted = "user:deleted"
|
||||
)
|
||||
|
||||
// --- Admin & System Events ---
|
||||
const (
|
||||
// EventTopicConfigChanged fires when a system configuration value changes.
|
||||
EventTopicConfigChanged = "admin:config_changed"
|
||||
|
||||
// EventTopicSystemCleanup fires when a periodic system cleanup completes.
|
||||
EventTopicSystemCleanup = "admin:system_cleanup"
|
||||
)
|
||||
|
||||
// --- Upload / Storage Events ---
|
||||
const (
|
||||
// EventTopicUploadCreated fires when a new file upload is recorded.
|
||||
EventTopicUploadCreated = "upload:created"
|
||||
|
||||
// EventTopicUploadDeleted fires when a file upload is removed.
|
||||
EventTopicUploadDeleted = "upload:deleted"
|
||||
|
||||
// EventTopicIngestComplete fires when a programmatic file ingest finishes.
|
||||
EventTopicIngestComplete = "upload:ingest_complete"
|
||||
)
|
||||
|
||||
// --- Message Gateway Events ---
|
||||
const (
|
||||
// EventTopicNotificationSent fires when a push notification is dispatched.
|
||||
EventTopicNotificationSent = "message:notification_sent"
|
||||
|
||||
// EventTopicChannelBound fires when a user binds a messaging channel.
|
||||
EventTopicChannelBound = "message:channel_bound"
|
||||
|
||||
// EventTopicChannelUnbound fires when a user unbinds a messaging channel.
|
||||
EventTopicChannelUnbound = "message:channel_unbound"
|
||||
)
|
||||
|
||||
// --- Risk Control Events ---
|
||||
const (
|
||||
// EventTopicAccessLogRecorded fires when a user access log entry is recorded.
|
||||
EventTopicAccessLogRecorded = "risk:access_log_recorded"
|
||||
)
|
||||
|
||||
// ======================================================================
|
||||
// Domain Event Payload DTOs
|
||||
// ======================================================================
|
||||
|
||||
// AdminLoggedIn 管理员登录领域事件载荷
|
||||
type AdminLoggedIn struct {
|
||||
User *UserDTO `json:"user"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// UserCreatedEvent fires when a new user account is created.
|
||||
type UserCreatedEvent struct {
|
||||
User *UserDTO `json:"user"`
|
||||
Password string `json:"-"`
|
||||
}
|
||||
|
||||
// ConfigChangedEvent fires when a system configuration value changes.
|
||||
type ConfigChangedEvent struct {
|
||||
Key string `json:"key"`
|
||||
OldVal any `json:"old_val,omitempty"`
|
||||
NewVal any `json:"new_val,omitempty"`
|
||||
}
|
||||
|
||||
// UploadCreatedEvent fires when a new file upload is recorded.
|
||||
type UploadCreatedEvent struct {
|
||||
UploadID uint64 `json:"upload_id,string"`
|
||||
UserID uint64 `json:"user_id,string"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
}
|
||||
|
||||
// NotificationSentEvent fires when a push notification is dispatched.
|
||||
type NotificationSentEvent struct {
|
||||
UserID uint64 `json:"user_id,string"`
|
||||
Channel string `json:"channel"`
|
||||
Title string `json:"title"`
|
||||
Success bool `json:"success"`
|
||||
ErrorInfo string `json:"error_info,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// LoggerService defines the contract for structured logging with trace ID and context correlation.
|
||||
type LoggerService interface {
|
||||
// Debug logs a debug message with optional key-value structured fields.
|
||||
Debug(ctx context.Context, msg string, keysAndValues ...any)
|
||||
|
||||
// Info logs an informational message with optional key-value structured fields.
|
||||
Info(ctx context.Context, msg string, keysAndValues ...any)
|
||||
|
||||
// Warn logs a warning message with optional key-value structured fields.
|
||||
Warn(ctx context.Context, msg string, keysAndValues ...any)
|
||||
|
||||
// Error logs an error message with optional key-value structured fields.
|
||||
Error(ctx context.Context, msg string, keysAndValues ...any)
|
||||
|
||||
// Debugf logs a formatted debug message.
|
||||
Debugf(ctx context.Context, format string, args ...any)
|
||||
|
||||
// Infof logs a formatted informational message.
|
||||
Infof(ctx context.Context, format string, args ...any)
|
||||
|
||||
// Warnf logs a formatted warning message.
|
||||
Warnf(ctx context.Context, format string, args ...any)
|
||||
|
||||
// Errorf logs a formatted error message.
|
||||
Errorf(ctx context.Context, format string, args ...any)
|
||||
|
||||
// With returns a child logger enriched with additional key-value attributes.
|
||||
With(keysAndValues ...any) LoggerService
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// StorageObject represents a retrieved file object from the storage backend.
|
||||
type StorageObject struct {
|
||||
Key string
|
||||
CachePath string
|
||||
Body io.ReadCloser
|
||||
ContentLength int64
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// StoragePutResult describes the output of a successful Put operation.
|
||||
type StoragePutResult struct {
|
||||
Key string
|
||||
Bucket string
|
||||
}
|
||||
|
||||
// IngestOptions configures programmatic ingest of files into the platform storage.
|
||||
type IngestOptions struct {
|
||||
UserID uint64
|
||||
Type string
|
||||
FileName string
|
||||
MimeType string
|
||||
Extension string
|
||||
Size int64
|
||||
Policy int
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// IngestResult reports the outcome of a programmatic file ingest operation.
|
||||
type IngestResult struct {
|
||||
ID uint64
|
||||
Key string
|
||||
URL string
|
||||
Created bool
|
||||
Stored bool
|
||||
Resolved bool
|
||||
}
|
||||
|
||||
// StorageService defines the contract for unified object storage and managed file ingestion.
|
||||
type StorageService interface {
|
||||
// Put writes an object to storage.
|
||||
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (StoragePutResult, error)
|
||||
|
||||
// Get retrieves an object from storage.
|
||||
Get(ctx context.Context, key string) (*StorageObject, error)
|
||||
|
||||
// Delete removes an object from storage.
|
||||
Delete(ctx context.Context, key string) error
|
||||
|
||||
// Ingest performs managed file ingestion into the platform storage domain with deduplication and metadata tracking.
|
||||
Ingest(ctx context.Context, reader io.Reader, opts IngestOptions) (*IngestResult, error)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// CreateUserRequest contains fields to register or create a new user.
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
// UpdateUserProfileRequest contains fields for updating a user's profile.
|
||||
type UpdateUserProfileRequest struct {
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Gender *string `json:"gender,omitempty"`
|
||||
Website *string `json:"website,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
}
|
||||
|
||||
// UserService defines the contract for user account management and profile queries.
|
||||
type UserService interface {
|
||||
// GetUserByID retrieves a user by ID.
|
||||
GetUserByID(ctx context.Context, id uint64) (*UserDTO, error)
|
||||
|
||||
// GetUserByUsername retrieves a user by username.
|
||||
GetUserByUsername(ctx context.Context, username string) (*UserDTO, error)
|
||||
|
||||
// GetUserByEmail retrieves a user by email.
|
||||
GetUserByEmail(ctx context.Context, email string) (*UserDTO, error)
|
||||
|
||||
// CreateUser registers or creates a new user account.
|
||||
CreateUser(ctx context.Context, req CreateUserRequest) (*UserDTO, error)
|
||||
|
||||
// UpdateProfile updates the profile of the specified user.
|
||||
UpdateProfile(ctx context.Context, id uint64, req UpdateUserProfileRequest) (*UserDTO, error)
|
||||
|
||||
// UpdatePassword updates the password for the specified user after verifying the old password.
|
||||
UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error
|
||||
|
||||
// VerifyPassword verifies if the given password matches the user's password.
|
||||
VerifyPassword(ctx context.Context, id uint64, password string) bool
|
||||
|
||||
// UpdateLastLogin updates the user's last login timestamp.
|
||||
UpdateLastLogin(ctx context.Context, id uint64, ip string) error
|
||||
|
||||
// ListUsers returns a paginated list of users with optional keyword search.
|
||||
ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*UserDTO, int64, error)
|
||||
|
||||
// SetUserActive sets the active/banned status for a user.
|
||||
SetUserActive(ctx context.Context, id uint64, active bool) error
|
||||
|
||||
// SetUserAdmin sets the admin role status for a user.
|
||||
SetUserAdmin(ctx context.Context, id uint64, admin bool) error
|
||||
|
||||
// VerifyAccessToken verifies an access token hash and returns the user DTO and isAdmin flag.
|
||||
VerifyAccessToken(ctx context.Context, tokenHash string) (*UserDTO, bool, error)
|
||||
|
||||
// DeleteUser removes a user and related access tokens.
|
||||
DeleteUser(ctx context.Context, id uint64) error
|
||||
|
||||
// CountUsers returns total user count.
|
||||
CountUsers(ctx context.Context) (int64, error)
|
||||
|
||||
// CountActiveUsers returns active user count.
|
||||
CountActiveUsers(ctx context.Context) (int64, error)
|
||||
|
||||
// GetFirstAdminUser returns the earliest admin user.
|
||||
GetFirstAdminUser(ctx context.Context) (*UserDTO, error)
|
||||
|
||||
// UniqueUsername generates a unique username candidate based on base.
|
||||
UniqueUsername(ctx context.Context, base string) (string, error)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const maxHandlerParams = 2
|
||||
|
||||
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 > maxHandlerParams {
|
||||
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.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
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,318 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
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/backend/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,280 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/backend/core"
|
||||
"github.com/Rain-kl/Wavelet/backend/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,87 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package extpoints defines extension points for router, migrations, tasks, schedules, and settings.
|
||||
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,269 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
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,103 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
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,80 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
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,122 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Manifest defines the metadata and dependency declarations for a plugin.
|
||||
type Manifest struct {
|
||||
// Name is the unique identifier for the plugin (e.g. "auth", "user", "order").
|
||||
Name string `json:"name" yaml:"name"`
|
||||
|
||||
// Version is the semantic version string of the plugin (e.g. "1.0.0").
|
||||
Version string `json:"version,omitempty" yaml:"version,omitempty"`
|
||||
|
||||
// Description gives a brief summary of the plugin capabilities.
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
||||
|
||||
// Author specifies the author or maintainer of the plugin.
|
||||
Author string `json:"author,omitempty" yaml:"author,omitempty"`
|
||||
|
||||
// Dependencies lists the plugin names that this plugin depends on.
|
||||
Dependencies []string `json:"dependencies,omitempty" yaml:"dependencies,omitempty"`
|
||||
|
||||
// Metadata holds arbitrary plugin-specific metadata.
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks whether the manifest satisfies basic integrity requirements.
|
||||
func (m Manifest) Validate() error {
|
||||
if strings.TrimSpace(m.Name) == "" {
|
||||
return fmt.Errorf("%w: %w", ErrInvalidManifest, ErrInvalidManifestName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/backend/core/extpoints"
|
||||
)
|
||||
|
||||
// Standard sentinel errors returned by core operations.
|
||||
var (
|
||||
// ErrServiceNotFound is returned when a requested service is not registered in the IoC container.
|
||||
ErrServiceNotFound = errors.New("core: service not found")
|
||||
|
||||
// ErrServiceNotReady is returned when one or more required services are not ready in Using/UsingN.
|
||||
ErrServiceNotReady = errors.New("core: service not ready")
|
||||
|
||||
// ErrNilContext is returned when a nil Context is passed to an operation requiring a valid Context.
|
||||
ErrNilContext = errors.New("core: context is nil")
|
||||
|
||||
// ErrNilService is returned when attempting to provide a nil service implementation.
|
||||
ErrNilService = errors.New("core: service is nil")
|
||||
|
||||
// ErrInvalidManifest is returned when a plugin manifest fails validation.
|
||||
ErrInvalidManifest = errors.New("core: invalid manifest")
|
||||
|
||||
// ErrInvalidManifestName is returned when a plugin manifest has an empty name.
|
||||
ErrInvalidManifestName = errors.New("core: manifest name is required")
|
||||
|
||||
// ErrDriverNotFound is returned when a requested driver type is not registered.
|
||||
ErrDriverNotFound = errors.New("core: driver not found")
|
||||
|
||||
// ErrAppRunning is returned when attempting to start an already running App.
|
||||
ErrAppRunning = errors.New("core: app is already running")
|
||||
|
||||
// ErrAppNotRunning is returned when attempting to operate on an App that is not running.
|
||||
ErrAppNotRunning = errors.New("core: app is not running")
|
||||
)
|
||||
|
||||
// Plugin is the unified contract for all core and downstream plugins.
|
||||
type Plugin interface {
|
||||
// Name returns the globally unique identifier of the plugin (e.g. "auth", "database").
|
||||
Name() string
|
||||
// Apply is the core mounting entrypoint: provides services, registers routes, tasks, and event listeners.
|
||||
Apply(ctx *Context) error
|
||||
}
|
||||
|
||||
// PluginWithManifest is an optional extension interface for plugins that declare metadata.
|
||||
type PluginWithManifest interface {
|
||||
Plugin
|
||||
Manifest() Manifest
|
||||
}
|
||||
|
||||
// DriverType identifies the category of a runtime driver engine.
|
||||
type DriverType string
|
||||
|
||||
const (
|
||||
// DriverTypeHTTP represents HTTP web server drivers (e.g. Gin).
|
||||
DriverTypeHTTP DriverType = "http"
|
||||
|
||||
// DriverTypeWorker represents asynchronous background worker drivers (e.g. Asynq worker server).
|
||||
DriverTypeWorker DriverType = "worker"
|
||||
|
||||
// DriverTypeScheduler represents cron and timer schedule drivers (e.g. Asynq scheduler).
|
||||
DriverTypeScheduler DriverType = "schedule"
|
||||
)
|
||||
|
||||
// Driver is a runtime engine that manages an event loop or listening port.
|
||||
type Driver interface {
|
||||
// Type returns the category of this driver engine.
|
||||
Type() DriverType
|
||||
// Start starts the driver lifecycle loop.
|
||||
Start(ctx context.Context) error
|
||||
// Stop gracefully shuts down the driver.
|
||||
Stop(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Profile identifies the runtime aspect or execution mode of an application.
|
||||
type Profile string
|
||||
|
||||
const (
|
||||
// ProfileAPI runs HTTP API server drivers.
|
||||
ProfileAPI Profile = "api"
|
||||
|
||||
// ProfileWorker runs asynchronous background worker drivers.
|
||||
ProfileWorker Profile = "worker"
|
||||
|
||||
// ProfileSchedule runs cron and timer schedule drivers.
|
||||
ProfileSchedule Profile = "schedule"
|
||||
|
||||
// ProfileAll runs all registered drivers concurrently in fused mode.
|
||||
ProfileAll Profile = "all"
|
||||
)
|
||||
|
||||
// MigrationEngine is the interface for executing database migrations across registered plugins.
|
||||
// The ctx parameter is the root micro-kernel Context, allowing the engine to resolve
|
||||
// services from the IoC container via core.Inject or core.Using.
|
||||
type MigrationEngine interface {
|
||||
Migrate(ctx *Context, entries []MigrationEntry) error
|
||||
}
|
||||
|
||||
// MigrationRunner is a function adapter implementing MigrationEngine.
|
||||
type MigrationRunner func(ctx *Context, entries []MigrationEntry) error
|
||||
|
||||
// Migrate calls the underlying migration function.
|
||||
func (fn MigrationRunner) Migrate(ctx *Context, entries []MigrationEntry) error {
|
||||
return fn(ctx, entries)
|
||||
}
|
||||
|
||||
// Disposer is a cleanup function executed when a Context is disposed.
|
||||
type Disposer func() error
|
||||
|
||||
// RouterExtension re-exports extpoints.RouterExtension.
|
||||
type RouterExtension = extpoints.RouterExtension
|
||||
|
||||
// RouteDefinition re-exports extpoints.RouteDefinition.
|
||||
type RouteDefinition = extpoints.RouteDefinition
|
||||
|
||||
// MigrationExtension re-exports extpoints.MigrationExtension.
|
||||
type MigrationExtension = extpoints.MigrationExtension
|
||||
|
||||
// MigrationEntry re-exports extpoints.MigrationEntry.
|
||||
type MigrationEntry = extpoints.MigrationEntry
|
||||
|
||||
// TaskExtension re-exports extpoints.TaskExtension.
|
||||
type TaskExtension = extpoints.TaskExtension
|
||||
|
||||
// TaskDefinition re-exports extpoints.TaskDefinition.
|
||||
type TaskDefinition = extpoints.TaskDefinition
|
||||
|
||||
// TaskOption re-exports extpoints.TaskOption.
|
||||
type TaskOption = extpoints.TaskOption
|
||||
|
||||
// ScheduleExtension re-exports extpoints.ScheduleExtension.
|
||||
type ScheduleExtension = extpoints.ScheduleExtension
|
||||
|
||||
// ScheduleDefinition re-exports extpoints.ScheduleDefinition.
|
||||
type ScheduleDefinition = extpoints.ScheduleDefinition
|
||||
|
||||
// ScheduleOption re-exports extpoints.ScheduleOption.
|
||||
type ScheduleOption = extpoints.ScheduleOption
|
||||
|
||||
// SettingExtension re-exports extpoints.SettingExtension.
|
||||
type SettingExtension = extpoints.SettingExtension
|
||||
|
||||
// SettingSchema re-exports extpoints.SettingSchema.
|
||||
type SettingSchema = extpoints.SettingSchema
|
||||
Reference in New Issue
Block a user