mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
feat(cordis): add OpenFlare Cordis 架构改造设计
docs(changelog): 修正表述笔误
refactor(cordis): 磁盘缓存改用上上游能力并清理本地副本
按上游/下游归属规约:类型断言守卫已回流 Wavelet(f3d85d5,附回归用例),
本仓库删除 OpenFlare/plugins/server/pkg/cache 整包并改 import 到
Wavelet/pkg/cache/disk,同步后与上游零漂移。
验证:go build 通过;go test ./... exit 0(137 包 ok);256 条路由对拍与
232 条 swagger 操作均零差异;make build-all 四进制;前端零改动。
docs(cordis): 记录 T1 清理结果与五个复用阻塞点
refactor(cordis): server 复用上游 pkg 能力并删除等价本地副本
按上游/下游归属规约清理重复实现,删除 7 个与上游等价的本地包并改 import:
shared/response→pkg/response、pkg/{logger,mail,trace,httppool,cache/ram}→
上游同名包、infra/persistence/batchwriter→pkg/batchwriter。逐项核过差异:
httppool 逐字节相同;logger 的 Config 字段完全一致;response 的 7 个 Abort*
一致;cache/ram 换过去顺带把裸 go 变回带 panic 恢复的 util.Go。
两处非等价差异按语义处理:
- batchwriter.Stats 与 status DTO 原为类型别名,改为消费侧逐字段转换,
避免 model 反向依赖基础设施类型;
- 上游 pkg/idgen 要求显式 Init(本地副本为懒加载自动初始化),本次保留本地
副本,待与 infra 初始化一并迁移(已登记在清理计划)。
验证:go build 通过;go test ./... exit 0(138 包 ok);256 条路由对拍零差异;
make swagger 232 条操作零增减,且归一化后与旧文档深度相等——差异仅为
response.Any / logger.LogEntry 两个定义名随包路径改名,接口形状未变。
chore(cordis): 回流内核与 pkg/util 通用能力并清理 vendoring 污染
按新增的上游/下游归属规约:HandleRaw/BasePath 与版本比较、网络、格式化助手
属通用能力,已提交到 Wavelet 分支 feat/cordis-router-raw-routes,本仓库改为
纯同步获取(pkg/util 已零漂移),补丁登记保留至上游合并。
同时修掉我此前 git add -A 造成的污染:首次 vendoring 把上游工作区里被
gitignore 的运行期产物一起提交进来(upload 的 diskcache 缓存块 650 个与
driver_http/dist 前端构建物 380 个,共 12872 行/1030 文件)。sync-upstream.sh
现显式排除 uploads/dist/data/*.db,.gitignore 补上对应兜底规则。
AGENTS.md 增加上游/下游改动归属规约,并把仍指向前 Cordis 布局的硬性约束
(internal/router + Serve、internal/repository/logstore、internal/platform/bootstrap、
internal/cmd)改到当前插件路径。
验证:go build 通过;go test ./... exit 0(144 包 ok);make swagger 232 条
操作与基线逐条一致;make build-all 四进制;gofmt 干净。
feat(cordis): server 插件化并改由内核挂载控制面路由
新增 plugins/server/plugin.go:Apply 以 ctx.Router().Group(app.api_prefix)
声明根级与 /v1 全部路由;33 个注册函数由 *gin.RouterGroup 改为
core.RouterExtension,RegisterCollection 改用内核新增的 HandleRaw 保留
尾部斜杠变体,AdminMiddlewares 返回 []any(Go 不允许把 []T 展开为 ...any)。
删除 router.Serve 与 registerRoutes,装配根改为 core.App +
driver_http.New(WithEngine(router.BuildEngine())),监听、信号与优雅退出归内核;
前端 SPA 的 NoRoute 兜底因内核暂无贡献点而保留在引擎层。
路由保真证据:plugin_parity_test 对拍 baseline/routes-engine.txt 的 256 条
(方法 路径) 零差异;go test ./... exit 0(144 包 ok,含真实 handler 的
openflare/integration 用例走同一条挂载路径);make swagger 232 条操作与基线
逐条一致;golangci-lint 0 issues;make build-all 四进制;embed_frontend
标签编译通过;前端零改动。
已知待补:带 Redis 的实机 HTTP 冒烟(本机 6379 未启动,session store 与
改造前一样在建店阶段即 fatal),以及 bootstrap 的任务/设置/迁移注册迁入 Apply。
feat(core): RouterExtension 增加 HandleRaw 与 BasePath 以保真尾部斜杠路由
server 插件化的前置:Handle 经 cleanPath 会剥掉尾部斜杠,无法表达
/resource 与 /resource/ 两条不同路由,而 OpenFlare 有 20 个历史 list
端点两者都注册且部署关闭了 RedirectTrailingSlash,缺失即 404。新增
HandleRaw 与 BasePath(作用域包装器同样登记反注册),补 extpoints 用例;
并把 router.Serve 拆出 BuildEngine 以便交给 driver_http.WithEngine 复用,
新增路由表导出 harness,固化 256 条 (方法 路径) 基线供插件化对拍。
上游补丁登记于 backend/OpenFlare/upstream-patches.md,同步脚本改为按目录
前缀输出差异并在同步后提醒确认补丁是否仍在。
验证:go build 通过;go test ./... exit 0(143 包 ok);gofmt 干净。
docs(cordis): 记录 server 插件接入内核的可行路径与内核能力缺口
feat(cordis): agent/relay/flared 落地为内核驱动插件
三个边缘守护进程各新增 plugin.go,实现 core.Plugin + core.Driver
(自定义 DriverType 与同名 profile),装配与生命周期从 main 迁入
Apply/Start/Stop:Apply 负责 JSON 配置加载、运行环境与用户确保、
openresty/frps/frpc 管理器与各服务装配;Start 以 util.Go 拉起阻塞式
runner 与 GeoIP 周期更新;Stop 收敛主循环结果并在超时时报错而非静默。
入口改为 core.NewApp(core.WithProfile(...)) + Prepare/Run,保持
-config 旗标、默认路径、退出码与启动/停止日志不变。
验证:go build 通过;go test ./... exit 0(143 包 ok,含 3 个插件身份
与配置失败路径测试);make build-all 四进制产出;三进制实跑缺失配置
均 exit 1 且错误链保留 load {agent,relay,flared} config 原因;gofmt 干净。
refactor(cordis): 按功能职责拆分为 4 个插件与 share 共享层
backend/OpenFlare 不再平铺遗留分层,改为 plugins/{server,agent,relay,flared}
加 share/:控制面业务(openflare/admin/oauth/user/upload/cap/config/health 与
repository/model/infra/router 等支撑层)归 server;三个边缘守护进程各自成插件;
被两个以上插件消费的 protocol/geoip/wsclient/render/pagesarchive/edge 归 share。
同时把 pkg/util 与 buildinfo 合并回上游 pkg(上游已覆盖全部符号,仅 8 个函数与
2 个类型为 OpenFlare 独有,已一并迁入),装配根统一到 backend/cmd(含三个 daemon
入口),Dockerfile 与 release 工作流的构建路径和 -X 注入路径同步更新。
验证:go build 通过;go test ./... exit 0(141 包 ok);make swagger exit 0 且
232 条 API 操作与基线逐条一致;make build-all 产出 4 进制;-X 注入经二进制
strings 实测生效;日志后端直连门禁改写为按 server 插件业务域扫描并在扫描数为 0
时报错(防门禁静默失效);前端零改动。
feat(cordis): 落地 backend/share 共享层与上游同步脚本
跨插件共享资源(控制消息协议、GeoIP+iputil、边缘守护进程日志)从下游包
移入 backend/share,并声明其只能依赖 core/pkg 与标准/第三方库,禁止反向
引用下游业务与具体插件实现;新增 scripts/sync-upstream.sh 只覆盖
backend/{core,pkg,plugins},同步后 --check 报告零差异,证明与上游逐字一致。
go build 通过,go test ./... exit 0(142 包 ok),前端零改动。
refactor(cordis): 采用与 Wavelet 同构的单模块布局并引入上游内核
按上游结构落位:backend/{core,pkg,plugins} 为 Wavelet 上游拷贝,OpenFlare
全部业务收拢到上游 downstream 所对应的位置 backend/OpenFlare/,模块名保持
Wavelet 以保证上游 import 路径逐字一致、同步零改写;三个 daemon 入口移至
backend/OpenFlare/cmd,backend/cmd 与 main.go 作为控制面装配根。
行为不变:go build 通过,142 个测试包全绿(含上游插件测试),232 条 API
操作与改造前逐条一致,四进制产物正常,前端零改动。swagger 暂只扫描下游代码,
待 P4 挂载上游路由后再纳入 plugins/。
style: 修正模块路径改写导致的 import 分组排序漂移
refactor(layout): Go 代码迁入 backend/ 并将模块名简化为 OpenFlare
对齐上游 Wavelet 的仓库布局,为以第二 module 形态 vendoring Cordis 内核与
平台插件做准备:模块路径整体改写为 OpenFlare,Go 目标加 cd backend,
swaggo 产物移至 backend/docs 并把 json/yaml 复制回 docs/ 供站点消费,
Dockerfile 与 release 工作流的构建目录、ldflags 模块路径同步更新。
行为保持不变:232 条路由与改造前逐条一致,95 个测试包全绿,
四进制产物正常,前端零改动。
chore(cordis): 落地改造计划与 schema/路由基线
新增 legacy_dump_test 迁移快照 harness:在临时 sqlite 库上按生产顺序
(goose.UpTo → zone 导入 → goose.Up)跑完 76 个历史迁移并导出 schema 与
版本序列,作为改造前后一致性门禁的唯一事实来源。同时记录 232 条路由清单
与 foundation 实施计划。
docs(cordis): add OpenFlare Cordis 架构改造设计
明确上游以第二 module 形态 vendoring 进 backend/Wavelet、4 个插件
(server/agent/relay/flared) 全部装载内核,并规定保留 76 个历史 goose
迁移 + 一次性版本 stamp 桥接的迁移方案,配套三方 schema 一致性门禁,
确保已部署库不重跑历史、不丢数据。
This commit is contained in:
@@ -0,0 +1,697 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfigSource installs the raw configuration source adapter, typically built by an
|
||||
// infrastructure package outside the kernel, before any plugin is applied.
|
||||
func WithConfigSource(src ConfigSource) AppOption {
|
||||
return func(a *App) {
|
||||
if src == nil {
|
||||
return
|
||||
}
|
||||
// Installed during Prepare so the option order, including WithContext, is irrelevant.
|
||||
a.configSource = src
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfigDecl lets the composition root declare the configuration it reads itself,
|
||||
// so host-level values take part in conflict validation and the redacted report. The
|
||||
// bindings are registered during Prepare, so option order does not matter.
|
||||
func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption {
|
||||
return func(a *App) {
|
||||
if len(bindings) == 0 {
|
||||
return
|
||||
}
|
||||
if a.hostDeclOwner == "" {
|
||||
a.hostDeclOwner = pluginID
|
||||
}
|
||||
a.hostDeclBindings = append(a.hostDeclBindings, bindings...)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
fibers []*Fiber
|
||||
fiberMap map[string]*Fiber
|
||||
applied bool
|
||||
running bool
|
||||
startedDrivers []Driver
|
||||
migrationEngine MigrationEngine
|
||||
shutdownTimeout time.Duration
|
||||
configSource ConfigSource
|
||||
hostDeclOwner string
|
||||
hostDeclBindings []ConfigBinding
|
||||
prepared bool
|
||||
applyErr error
|
||||
}
|
||||
|
||||
// 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),
|
||||
fiberMap: make(map[string]*Fiber),
|
||||
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
|
||||
}
|
||||
}
|
||||
if existingFiber, ok := a.fiberMap[name]; ok {
|
||||
existingFiber.plugin = p
|
||||
}
|
||||
} else {
|
||||
a.plugins = append(a.plugins, p)
|
||||
f := NewFiber(a.ctx, p)
|
||||
a.fibers = append(a.fibers, f)
|
||||
a.fiberMap[name] = f
|
||||
}
|
||||
a.pluginMap[name] = p
|
||||
|
||||
if gated, ok := p.(ConfigGatedPlugin); ok && a.applyErr == nil {
|
||||
// Gates are evaluated before Apply, so their keys must be declared at mount time.
|
||||
a.applyErr = a.ctx.Config().Declare(name, gated.DeclareConfig()...)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Fibers returns a copy of all plugin Fibers.
|
||||
func (a *App) Fibers() []*Fiber {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
res := make([]*Fiber, len(a.fibers))
|
||||
copy(res, a.fibers)
|
||||
return res
|
||||
}
|
||||
|
||||
// Fiber retrieves a Fiber by its unique plugin name.
|
||||
func (a *App) Fiber(name string) (*Fiber, bool) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
f, ok := a.fiberMap[name]
|
||||
return f, 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)
|
||||
}
|
||||
|
||||
// Reconcile evaluates all pending Fibers and reactively transitions them to ACTIVE
|
||||
// as their declared dependencies become satisfied.
|
||||
func (a *App) Reconcile() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.reconcileLocked()
|
||||
}
|
||||
|
||||
func (a *App) reconcileLocked() error {
|
||||
if err := a.prepareLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
progress := false
|
||||
for _, f := range a.fibers {
|
||||
if f.State() != FiberPending {
|
||||
continue
|
||||
}
|
||||
|
||||
gated, skip, err := a.evaluateGateLocked(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gated && skip {
|
||||
if err := f.Skip(); err != nil {
|
||||
return fmt.Errorf("core: skip gated fiber %q failed: %w", f.Name(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if f.DependenciesSatisfied(a.ctx) {
|
||||
if err := f.Load(); err != nil {
|
||||
return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err)
|
||||
}
|
||||
progress = true
|
||||
}
|
||||
}
|
||||
if !progress {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var unsatisfied []string
|
||||
for _, f := range a.fibers {
|
||||
if f.State() == FiberPending {
|
||||
unsatisfied = append(unsatisfied, fmt.Sprintf("%s (waiting for %v)", f.Name(), f.Dependencies()))
|
||||
}
|
||||
}
|
||||
if len(unsatisfied) > 0 {
|
||||
return fmt.Errorf("core: unsatisfied dependencies for plugins: %s", strings.Join(unsatisfied, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluateGateLocked reports whether a configuration-gated plugin is excluded by the
|
||||
// resolved values. Plugins that do not implement the gate interface are never skipped.
|
||||
func (a *App) evaluateGateLocked(f *Fiber) (gated bool, skip bool, err error) {
|
||||
gatedPlugin, ok := f.plugin.(ConfigGatedPlugin)
|
||||
if !ok {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
view := a.ctx.Config()
|
||||
if !view.Resolved() {
|
||||
return true, false, fmt.Errorf(
|
||||
"core: plugin %q is configuration-gated but the App has no ConfigSource; "+
|
||||
"pass core.WithConfigSource or remove DeclareConfig", f.Name())
|
||||
}
|
||||
|
||||
return true, !gatedPlugin.ConfigEnabled(view), nil
|
||||
}
|
||||
|
||||
// ApplyPlugins applies all registered plugins on the application Context via reactive reconciliation.
|
||||
// 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
|
||||
|
||||
declaredErr, prepareErr := a.applyErr, a.prepareLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
if declaredErr != nil {
|
||||
return declaredErr
|
||||
}
|
||||
if prepareErr != nil {
|
||||
return prepareErr
|
||||
}
|
||||
|
||||
return a.Reconcile()
|
||||
}
|
||||
|
||||
// Prepare resolves declared configuration and establishes the resolution barrier that
|
||||
// gates and plugin Bind calls depend on. It is idempotent and runs implicitly from
|
||||
// ApplyPlugins; callers that need resolved values earlier — for example to size a
|
||||
// shutdown budget — invoke it explicitly right after mounting plugins.
|
||||
func (a *App) Prepare() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.applyErr != nil {
|
||||
return a.applyErr
|
||||
}
|
||||
return a.prepareLocked()
|
||||
}
|
||||
|
||||
// prepareLocked installs the injected source, registers host declarations and resolves
|
||||
// every declared key once. An App without a ConfigSource leaves configuration unused,
|
||||
// so kernel-level usage stays opt-in for embedders that configure nothing.
|
||||
func (a *App) prepareLocked() error {
|
||||
if a.prepared {
|
||||
return nil
|
||||
}
|
||||
if a.configSource == nil {
|
||||
a.prepared = true
|
||||
return nil
|
||||
}
|
||||
|
||||
config := a.ctx.Config()
|
||||
config.SetSource(a.configSource)
|
||||
|
||||
if err := config.Declare(a.hostDeclOwner, a.hostDeclBindings...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := config.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
a.prepared = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShutdownTimeout returns the graceful shutdown budget for the application.
|
||||
func (a *App) ShutdownTimeout() time.Duration {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.shutdownTimeout
|
||||
}
|
||||
|
||||
// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive
|
||||
// values so a missing configuration key can never shrink the kernel fallback to zero.
|
||||
func (a *App) SetShutdownTimeout(timeout time.Duration) *App {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if timeout > 0 {
|
||||
a.shutdownTimeout = timeout
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// 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.
|
||||
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.
|
||||
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. Unload fibers in reverse order
|
||||
a.mu.RLock()
|
||||
fibers := make([]*Fiber, len(a.fibers))
|
||||
copy(fibers, a.fibers)
|
||||
a.mu.RUnlock()
|
||||
|
||||
for i := len(fibers) - 1; i >= 0; i-- {
|
||||
if err := fibers[i].Unload(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("core: unload fiber %s failed: %w", fibers[i].Name(), err))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dispose root 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. It forwards a sigCtx derived from the caller's context to Start.
|
||||
//
|
||||
//nolint:contextcheck // the caller's ctx does reach Start via sigCtx; the rule cannot follow Run's variadic context parameter
|
||||
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.
|
||||
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,596 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/extpoints"
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 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 for app and driver to become ready
|
||||
assert.Eventually(t, func() bool {
|
||||
return app.IsRunning() && d.isStarted()
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
// newGateSource builds a configuration source whose only key decides the test gates.
|
||||
func newGateSource(enabled bool) *mapSource {
|
||||
return &mapSource{
|
||||
values: map[string]any{"gate.enabled": enabled},
|
||||
env: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppPrepareResolvesThenGatesDuringReconcile(t *testing.T) {
|
||||
primary := &gatedPlugin{name: "cache", enabled: true}
|
||||
fallback := &gatedPlugin{name: "cache_memory", enabled: false}
|
||||
|
||||
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
|
||||
app.Use(primary, fallback)
|
||||
require.NoError(t, app.Prepare())
|
||||
|
||||
cacheFiber, ok := app.Fiber("cache")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier")
|
||||
assert.True(t, app.Context().Config().Resolved())
|
||||
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
assert.Equal(t, core.FiberActive, cacheFiber.State())
|
||||
|
||||
memoryFiber, ok := app.Fiber("cache_memory")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, memoryFiber.State())
|
||||
assert.False(t, fallback.applied, "the gated-out provider must never reach Apply")
|
||||
}
|
||||
|
||||
func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) {
|
||||
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
|
||||
require.NoError(t, app.Prepare())
|
||||
|
||||
late := &gatedPlugin{name: "cache_memory", enabled: false}
|
||||
app.Use(late)
|
||||
require.NoError(t, app.Reconcile())
|
||||
|
||||
fiber, ok := app.Fiber("cache_memory")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, fiber.State(),
|
||||
"plugins mounted after Prepare must still be gated")
|
||||
}
|
||||
|
||||
func TestAppApplyPluginsGatesImplicitly(t *testing.T) {
|
||||
app := core.NewApp(core.WithConfigSource(newGateSource(false)))
|
||||
app.Use(&gatedPlugin{name: "cache", enabled: true})
|
||||
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
|
||||
fiber, ok := app.Fiber("cache")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, core.FiberSkipped, fiber.State(),
|
||||
"ApplyPlugins must resolve and gate without an explicit Prepare call")
|
||||
}
|
||||
|
||||
func TestAppPrepareReportsConfigurationErrors(t *testing.T) {
|
||||
src := &mapSource{
|
||||
values: map[string]any{"gate.enabled": "yes"},
|
||||
env: map[string]string{},
|
||||
}
|
||||
app := core.NewApp(core.WithConfigSource(src))
|
||||
app.Use(&gatedPlugin{name: "cache", enabled: true})
|
||||
|
||||
err := app.Prepare()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "gate.enabled")
|
||||
}
|
||||
|
||||
func TestAppGatedPluginWithoutConfigSourceFailsFast(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
app.Use(&gatedPlugin{name: "cache", enabled: true})
|
||||
|
||||
err := app.ApplyPlugins()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cache")
|
||||
assert.Contains(t, err.Error(), "ConfigSource")
|
||||
}
|
||||
|
||||
func TestAppSetShutdownTimeoutIgnoresNonPositive(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
|
||||
app.SetShutdownTimeout(0)
|
||||
assert.Equal(t, 10*time.Second, app.ShutdownTimeout(), "zero must not shrink the kernel fallback")
|
||||
|
||||
app.SetShutdownTimeout(45 * time.Second)
|
||||
assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"Wavelet/core/extpoints"
|
||||
)
|
||||
|
||||
// ConfigGet reads one resolved configuration value with its declared type. It is the
|
||||
// generic counterpart of the fallback accessors on ConfigView, used when a caller must
|
||||
// distinguish "unset" from "set to the zero value".
|
||||
func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) {
|
||||
var zero T
|
||||
if view == nil {
|
||||
return zero, extpoints.ErrConfigNotResolved
|
||||
}
|
||||
|
||||
raw, ok := view.Value(key)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key)
|
||||
}
|
||||
|
||||
value, ok := raw.(T)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// MapSource implements ConfigSource backed by an in-memory map, ideal for unit tests.
|
||||
type MapSource struct {
|
||||
values map[string]any
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
// NewMapSource creates a new MapSource with the provided key-value mappings.
|
||||
func NewMapSource(values map[string]any) *MapSource {
|
||||
vals := make(map[string]any, len(values))
|
||||
for k, v := range values {
|
||||
vals[k] = v
|
||||
}
|
||||
return &MapSource{
|
||||
values: vals,
|
||||
env: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the value at the given path, supporting both flat keys and nested maps.
|
||||
func (m *MapSource) Lookup(path string) (any, bool) {
|
||||
if m == nil || m.values == nil {
|
||||
return nil, false
|
||||
}
|
||||
if v, ok := m.values[path]; ok {
|
||||
return v, true
|
||||
}
|
||||
parts := strings.Split(path, ".")
|
||||
var cur any = m.values
|
||||
for _, part := range parts {
|
||||
mCur, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cur, ok = mCur[part]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cur, true
|
||||
}
|
||||
|
||||
// LookupEnv returns the environment variable value.
|
||||
func (m *MapSource) LookupEnv(name string) (string, bool) {
|
||||
if m == nil || m.env == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := m.env[name]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// SetEnv sets an environment variable for testing.
|
||||
func (m *MapSource) SetEnv(name, value string) {
|
||||
if m.env == nil {
|
||||
m.env = make(map[string]string)
|
||||
}
|
||||
m.env[name] = value
|
||||
}
|
||||
|
||||
// Describe describes the MapSource.
|
||||
func (m *MapSource) Describe() string {
|
||||
return "<map source>"
|
||||
}
|
||||
|
||||
// WithConfigValues returns an AppOption that installs a MapSource with the given key-value mappings.
|
||||
func WithConfigValues(values map[string]any) AppOption {
|
||||
return WithConfigSource(NewMapSource(values))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/extpoints"
|
||||
)
|
||||
|
||||
// mapSource implements extpoints.ConfigSource over static maps.
|
||||
type mapSource struct {
|
||||
values map[string]any
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
func (m *mapSource) Lookup(path string) (any, bool) {
|
||||
v, ok := m.values[path]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m *mapSource) LookupEnv(name string) (string, bool) {
|
||||
v, ok := m.env[name]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m *mapSource) Describe() string { return "map" }
|
||||
|
||||
type otelConfig struct {
|
||||
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
|
||||
}
|
||||
|
||||
// newOtelRegistry declares the otel section against a source carrying the given file values.
|
||||
func newOtelRegistry(t *testing.T, values map[string]any) extpoints.ConfigExtension {
|
||||
t.Helper()
|
||||
|
||||
r := extpoints.NewConfigRegistry(&mapSource{values: values, env: map[string]string{}})
|
||||
require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
return r
|
||||
}
|
||||
|
||||
func TestConfigGetReturnsDeclaredType(t *testing.T) {
|
||||
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
|
||||
|
||||
rate, err := core.ConfigGet[float64](view, "otel.sampling_rate")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0.25, rate)
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsTypeMismatch(t *testing.T) {
|
||||
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
|
||||
|
||||
text, err := core.ConfigGet[string](view, "otel.sampling_rate")
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigType)
|
||||
assert.Empty(t, text)
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsUndeclaredKey(t *testing.T) {
|
||||
view := newOtelRegistry(t, nil)
|
||||
|
||||
_, err := core.ConfigGet[float64](view, "otel.unregistered")
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
|
||||
}
|
||||
|
||||
func TestConfigGetRejectsNilView(t *testing.T) {
|
||||
_, err := core.ConfigGet[float64](nil, "otel.sampling_rate")
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigNotResolved)
|
||||
}
|
||||
|
||||
func TestContextConfigIsSharedAcrossForks(t *testing.T) {
|
||||
ctx := core.NewContext(nil)
|
||||
child := ctx.Fork()
|
||||
|
||||
require.NotNil(t, ctx.Config())
|
||||
assert.Same(t, ctx.Config(), child.Config(), "configuration declarations are process-wide facts")
|
||||
|
||||
require.NoError(t, child.Config().Declare("cache",
|
||||
extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
|
||||
|
||||
declared := false
|
||||
for _, entry := range ctx.Config().Entries() {
|
||||
declared = declared || entry.Key == "otel.sampling_rate"
|
||||
}
|
||||
assert.True(t, declared, "a declaration made in a plugin scope must be visible to the root")
|
||||
assert.False(t, ctx.Config().Resolved())
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) remove(targetType reflect.Type) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.services, targetType)
|
||||
}
|
||||
|
||||
// Provide registers a typed service implementation into the Context hierarchy's root 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]()
|
||||
targetContainer := ctx.Root().Container()
|
||||
targetContainer.provide(targetType, service)
|
||||
|
||||
ctx.OnDispose(func() error {
|
||||
targetContainer.remove(targetType)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ProvideScoped registers a typed service implementation strictly in the local Context container.
|
||||
func ProvideScoped[T any](ctx *Context, service T) {
|
||||
if ctx == nil {
|
||||
panic("core: nil context provided to ProvideScoped")
|
||||
}
|
||||
if isNil(service) {
|
||||
panic("core: cannot provide nil service")
|
||||
}
|
||||
|
||||
targetType := reflect.TypeFor[T]()
|
||||
targetContainer := ctx.Container()
|
||||
targetContainer.provide(targetType, service)
|
||||
|
||||
ctx.OnDispose(func() error {
|
||||
targetContainer.remove(targetType)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
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 err := errors.Join(err1, err2); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
|
||||
}
|
||||
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 err := errors.Join(err1, err2, err3); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
|
||||
}
|
||||
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,387 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"Wavelet/core/extpoints"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
config extpoints.ConfigExtension
|
||||
|
||||
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(),
|
||||
config: extpoints.NewConfigRegistry(nil),
|
||||
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, 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
|
||||
}
|
||||
|
||||
// Root returns the root Context in the hierarchy.
|
||||
func (c *Context) Root() *Context {
|
||||
curr := c
|
||||
for curr.parent != nil {
|
||||
curr = curr.parent
|
||||
}
|
||||
return curr
|
||||
}
|
||||
|
||||
// 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,
|
||||
config: c.config,
|
||||
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
|
||||
}
|
||||
|
||||
// On registers an event listener on the EventBus and automatically attaches its Disposer
|
||||
// to this Context's teardown stack for automatic revocation when disposed.
|
||||
func (c *Context) On(topic string, handler any) Disposer {
|
||||
disposer := c.events.On(topic, handler)
|
||||
c.OnDispose(disposer)
|
||||
return disposer
|
||||
}
|
||||
|
||||
// Effect registers a reversible side-effect cleanup callback on this Context.
|
||||
func (c *Context) Effect(fn any) {
|
||||
c.OnDispose(fn)
|
||||
}
|
||||
|
||||
// Router returns the scoped RouterExtension registry with automatic disposer tracking.
|
||||
func (c *Context) Router() extpoints.RouterExtension {
|
||||
return newScopedRouterExtension(c, c.router)
|
||||
}
|
||||
|
||||
// Migrations returns the MigrationExtension registry.
|
||||
func (c *Context) Migrations() extpoints.MigrationExtension {
|
||||
return c.migrations
|
||||
}
|
||||
|
||||
// Tasks returns the scoped TaskExtension registry with automatic disposer tracking.
|
||||
func (c *Context) Tasks() extpoints.TaskExtension {
|
||||
return newScopedTaskExtension(c, c.tasks)
|
||||
}
|
||||
|
||||
// Task is an alias for Tasks().
|
||||
func (c *Context) Task() extpoints.TaskExtension {
|
||||
return c.Tasks()
|
||||
}
|
||||
|
||||
// Schedules returns the scoped ScheduleExtension registry with automatic disposer tracking.
|
||||
func (c *Context) Schedules() extpoints.ScheduleExtension {
|
||||
return newScopedScheduleExtension(c, c.schedules)
|
||||
}
|
||||
|
||||
// Schedule is an alias for Schedules().
|
||||
func (c *Context) Schedule() extpoints.ScheduleExtension {
|
||||
return c.Schedules()
|
||||
}
|
||||
|
||||
// Settings returns the scoped SettingExtension registry with automatic disposer tracking.
|
||||
func (c *Context) Settings() extpoints.SettingExtension {
|
||||
return newScopedSettingExtension(c, c.settings)
|
||||
}
|
||||
|
||||
// Setting is an alias for Settings().
|
||||
func (c *Context) Setting() extpoints.SettingExtension {
|
||||
return c.Settings()
|
||||
}
|
||||
|
||||
// Config returns the process-level configuration extension point. The registry is
|
||||
// shared by every fork because configuration declarations are global facts, and it
|
||||
// carries no per-scope disposers: values are resolved once before Apply runs.
|
||||
func (c *Context) Config() extpoints.ConfigExtension {
|
||||
return c.config
|
||||
}
|
||||
|
||||
// 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 hierarchy.
|
||||
func (c *Context) RegisterDriver(d Driver) error {
|
||||
if d == nil {
|
||||
return ErrNilService
|
||||
}
|
||||
|
||||
root := c.Root()
|
||||
root.mu.Lock()
|
||||
root.drivers = append(root.drivers, d)
|
||||
root.mu.Unlock()
|
||||
|
||||
c.OnDispose(func() error {
|
||||
root.mu.Lock()
|
||||
defer root.mu.Unlock()
|
||||
for i, drv := range root.drivers {
|
||||
if drv == d {
|
||||
root.drivers = append(root.drivers[:i], root.drivers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Drivers returns a copy of all drivers registered on this Context.
|
||||
func (c *Context) Drivers() []Driver {
|
||||
root := c.Root()
|
||||
root.mu.RLock()
|
||||
defer root.mu.RUnlock()
|
||||
|
||||
result := make([]Driver, len(root.drivers))
|
||||
copy(result, root.drivers)
|
||||
return result
|
||||
}
|
||||
|
||||
// Driver looks up a registered driver by its driver type.
|
||||
func (c *Context) Driver(driverType DriverType) (Driver, bool) {
|
||||
root := c.Root()
|
||||
root.mu.RLock()
|
||||
defer root.mu.RUnlock()
|
||||
|
||||
for _, d := range root.drivers {
|
||||
if d.Type() == driverType {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// UsingN must keep every dependency failure reachable through the error chain,
|
||||
// not just report that something went wrong.
|
||||
func TestContextUsingMultipleErrorChain(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
err := core.Using2(ctx, func(s SampleService, l LogService) {
|
||||
t.Fatal("callback must not run when dependencies are missing")
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotReady)
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotFound)
|
||||
|
||||
// Only LogService is missing now, so exactly one joined cause must be present.
|
||||
core.Provide[SampleService](ctx, &sampleServiceImpl{})
|
||||
err = core.Using2(ctx, func(s SampleService, l LogService) {
|
||||
t.Fatal("callback must not run when a dependency is missing")
|
||||
})
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotReady)
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotFound)
|
||||
|
||||
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {
|
||||
t.Fatal("callback must not run when a dependency is missing")
|
||||
})
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotReady)
|
||||
assert.ErrorIs(t, err, core.ErrServiceNotFound)
|
||||
}
|
||||
|
||||
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.ProvideScoped[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 locally
|
||||
core.ProvideScoped[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.Migrations(), child.Migrations())
|
||||
assert.NotNil(t, child.Router())
|
||||
assert.NotNil(t, child.Tasks())
|
||||
assert.NotNil(t, child.Task())
|
||||
assert.NotNil(t, child.Schedules())
|
||||
assert.NotNil(t, child.Schedule())
|
||||
assert.NotNil(t, child.Settings())
|
||||
assert.NotNil(t, child.Setting())
|
||||
}
|
||||
|
||||
func TestContext_ScopedExtpoints_RevertibleEffects(t *testing.T) {
|
||||
root := core.NewContext(context.Background())
|
||||
child := root.Fork()
|
||||
|
||||
// Register route, task, schedule, setting, event on child
|
||||
child.Router().GET("/test-route", func() {})
|
||||
assert.Equal(t, 1, len(root.Router().Routes()))
|
||||
|
||||
child.Tasks().Register("test:task", func() {})
|
||||
assert.Equal(t, 1, len(root.Tasks().Tasks()))
|
||||
|
||||
child.Schedules().RegisterCron("@hourly", "test:cron", nil)
|
||||
assert.Equal(t, 1, len(root.Schedules().Schedules()))
|
||||
|
||||
child.Settings().Register(core.SettingSchema{Key: "test.key", Default: "val"})
|
||||
assert.Equal(t, 1, len(root.Settings().Schemas()))
|
||||
|
||||
child.On("test:event", func() {})
|
||||
assert.Equal(t, 1, root.Events().Listeners("test:event"))
|
||||
|
||||
// Dispose child
|
||||
err := child.Dispose()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// All child effects should be cleanly revoked in LIFO order
|
||||
assert.Equal(t, 0, len(root.Router().Routes()))
|
||||
assert.Equal(t, 0, len(root.Tasks().Tasks()))
|
||||
assert.Equal(t, 0, len(root.Schedules().Schedules()))
|
||||
assert.Equal(t, 0, len(root.Settings().Schemas()))
|
||||
assert.Equal(t, 0, root.Events().Listeners("test:event"))
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// AuthSourceDTO represents an OAuth / OIDC authentication source.
|
||||
type AuthSourceDTO struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
|
||||
Scopes string `json:"scopes"`
|
||||
IconURL string `json:"icon_url"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AuthSourceViewDTO is a sanitized view of an AuthSource for admin display.
|
||||
type AuthSourceViewDTO struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IconURL string `json:"icon_url"`
|
||||
ClientSecretConfigured bool `json:"client_secret_configured"`
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// InvalidateCachedUser invalidates cached user profile data.
|
||||
InvalidateCachedUser(ctx context.Context, userID uint64)
|
||||
|
||||
// InvalidateCachedToken invalidates cached access token data.
|
||||
InvalidateCachedToken(ctx context.Context, tokenHash string)
|
||||
|
||||
// ListAuthSources lists all configured authentication sources.
|
||||
ListAuthSources(ctx context.Context) ([]AuthSourceViewDTO, error)
|
||||
|
||||
// CreateAuthSource creates a new authentication source.
|
||||
CreateAuthSource(ctx context.Context, source AuthSourceDTO) (*AuthSourceDTO, error)
|
||||
|
||||
// UpdateAuthSource updates an authentication source.
|
||||
UpdateAuthSource(ctx context.Context, id uint64, source AuthSourceDTO) (*AuthSourceDTO, error)
|
||||
|
||||
// DeleteAuthSource removes an authentication source.
|
||||
DeleteAuthSource(ctx context.Context, id uint64) error
|
||||
|
||||
// ToggleAuthSource toggles the active state of an authentication source.
|
||||
ToggleAuthSource(ctx context.Context, id uint64) (*AuthSourceDTO, 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,153 @@
|
||||
// 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"
|
||||
|
||||
// EventTopicUserStatusChanged fires when a user account active status changes.
|
||||
EventTopicUserStatusChanged = "user:status_changed"
|
||||
|
||||
// EventTopicTokenRevoked fires when an access token is revoked.
|
||||
// #nosec G101
|
||||
EventTopicTokenRevoked = "auth:token_revoked"
|
||||
)
|
||||
|
||||
// --- 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"
|
||||
)
|
||||
|
||||
// --- Task Events ---
|
||||
const (
|
||||
// EventTopicTaskCompleted fires when an asynchronous background task execution finishes.
|
||||
EventTopicTaskCompleted = "task:completed"
|
||||
)
|
||||
|
||||
// TaskCompletedEvent carries task execution outcome details.
|
||||
type TaskCompletedEvent struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskName string `json:"task_name"`
|
||||
TaskType string `json:"task_type"`
|
||||
Status string `json:"status"`
|
||||
Duration int64 `json:"duration"`
|
||||
ErrorMsg string `json:"error_msg,omitempty"`
|
||||
ResultMsg string `json:"result_msg,omitempty"`
|
||||
Payload string `json:"payload,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// --- 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"`
|
||||
}
|
||||
|
||||
// UserStatusChangedEvent fires when a user status is enabled/disabled.
|
||||
type UserStatusChangedEvent struct {
|
||||
UserID uint64 `json:"user_id,string"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// TokenRevokedEvent fires when an access token is revoked.
|
||||
type TokenRevokedEvent struct {
|
||||
UserID uint64 `json:"user_id,string"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
}
|
||||
|
||||
// UserDeletedEvent fires when a user account is deleted.
|
||||
type UserDeletedEvent struct {
|
||||
CurrentUserID uint64 `json:"current_user_id,string"`
|
||||
TargetUserID uint64 `json:"target_user_id,string"`
|
||||
}
|
||||
@@ -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,66 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// AccessLogFilterDTO defines filter criteria for querying user access logs.
|
||||
type AccessLogFilterDTO struct {
|
||||
UserIDs []uint64
|
||||
Path string
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
}
|
||||
|
||||
// AccessLogDTO represents a single access log entry.
|
||||
type AccessLogDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Status int32 `json:"status"`
|
||||
Latency int64 `json:"latency"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AccessLogDailyStatsDTO represents aggregate access statistics for a single day.
|
||||
type AccessLogDailyStatsDTO struct {
|
||||
Date string `json:"date"`
|
||||
PV uint64 `json:"pv"`
|
||||
UV uint64 `json:"uv"`
|
||||
IPCount uint64 `json:"ip_count"`
|
||||
ErrorCount uint64 `json:"error_count"`
|
||||
AvgLatencyMs int64 `json:"avg_latency_ms"`
|
||||
SlowReqCount uint64 `json:"slow_req_count"`
|
||||
MaxLatencyMs int64 `json:"max_latency_ms"`
|
||||
P95LatencyMs int64 `json:"p95_latency_ms"`
|
||||
P99LatencyMs int64 `json:"p99_latency_ms"`
|
||||
}
|
||||
|
||||
// RiskControlService defines the contract for accessing security risk control and audit logstore.
|
||||
type RiskControlService interface {
|
||||
// QueryAccessLogs retrieves paginated access logs matching the filter.
|
||||
QueryAccessLogs(ctx context.Context, filter AccessLogFilterDTO, page, pageSize int) ([]AccessLogDTO, uint64, error)
|
||||
|
||||
// QueryAccessLogStats returns aggregate daily statistics for the last N days.
|
||||
QueryAccessLogStats(ctx context.Context, days int) ([]AccessLogDailyStatsDTO, error)
|
||||
|
||||
// ActiveLogEngine returns the current active logstore engine name.
|
||||
ActiveLogEngine(ctx context.Context) string
|
||||
|
||||
// IsLogEngineMigrating reports whether a log engine migration is in progress.
|
||||
IsLogEngineMigrating(ctx context.Context) bool
|
||||
|
||||
// Drain flushes pending in-flight log buffers.
|
||||
Drain(ctx context.Context) error
|
||||
|
||||
// SwitchLogEngine migrates and switches the active log storage engine.
|
||||
SwitchLogEngine(ctx context.Context, targetEngine string) error
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// StorageDriver identifies a supported storage backend.
|
||||
type StorageDriver string
|
||||
|
||||
// Storage drivers supported by the platform. Values persist in storage configs.
|
||||
const (
|
||||
StorageDriverLocal StorageDriver = "local"
|
||||
StorageDriverS3 StorageDriver = "s3"
|
||||
StorageDriverR2 StorageDriver = "r2"
|
||||
StorageDriverMinIO StorageDriver = "minio"
|
||||
StorageDriverOSS StorageDriver = "oss"
|
||||
StorageDriverWebDAV StorageDriver = "webdav"
|
||||
)
|
||||
|
||||
// LocalStorageConfigDTO configures local filesystem storage.
|
||||
type LocalStorageConfigDTO struct {
|
||||
Root string `json:"root"`
|
||||
}
|
||||
|
||||
// ObjectStorageConfigDTO configures S3-compatible or OSS object storage.
|
||||
type ObjectStorageConfigDTO struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKeyID string `json:"access_key_id"`
|
||||
SecretAccessKey string `json:"secret_access_key"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
PathStyle bool `json:"path_style"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
CDNURL string `json:"cdn_url"`
|
||||
}
|
||||
|
||||
// WebDAVStorageConfigDTO configures WebDAV storage.
|
||||
type WebDAVStorageConfigDTO struct {
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Root string `json:"root"`
|
||||
}
|
||||
|
||||
// StorageConfigDTO encapsulates full storage configuration across all backends.
|
||||
type StorageConfigDTO struct {
|
||||
Driver StorageDriver `json:"driver"`
|
||||
Local LocalStorageConfigDTO `json:"local"`
|
||||
S3 ObjectStorageConfigDTO `json:"s3"`
|
||||
R2 ObjectStorageConfigDTO `json:"r2"`
|
||||
MinIO ObjectStorageConfigDTO `json:"minio"`
|
||||
OSS ObjectStorageConfigDTO `json:"oss"`
|
||||
WebDAV WebDAVStorageConfigDTO `json:"webdav"`
|
||||
}
|
||||
|
||||
// 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,79 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// TaskParamDTO describes a parameter accepted by a background task.
|
||||
type TaskParamDTO struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// TaskMetaDTO describes the metadata and configuration of a registered background task.
|
||||
type TaskMetaDTO struct {
|
||||
Type string `json:"type"`
|
||||
AsynqTask string `json:"asynq_task"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category,omitempty"`
|
||||
SupportsTime bool `json:"supports_time"`
|
||||
Params []TaskParamDTO `json:"params,omitempty"`
|
||||
MaxRetry int `json:"max_retry"`
|
||||
Timeout time.Duration `json:"timeout,omitempty"`
|
||||
Queue string `json:"queue"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Schedule string `json:"schedule,omitempty"`
|
||||
}
|
||||
|
||||
// TaskResultDTO represents the outcome of a background task execution.
|
||||
type TaskResultDTO struct {
|
||||
Message string `json:"message"`
|
||||
Detail any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// TaskExecutionDTO represents a single task execution record.
|
||||
type TaskExecutionDTO struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
TaskID string `json:"task_id"`
|
||||
TaskType string `json:"task_type"`
|
||||
TaskName string `json:"task_name"`
|
||||
Status string `json:"status"`
|
||||
Retryable bool `json:"retryable"`
|
||||
MaxRetry int `json:"max_retry"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
Log string `json:"log"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Result string `json:"result"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
Duration int64 `json:"duration"`
|
||||
Payload string `json:"payload"`
|
||||
TriggeredBy string `json:"triggered_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskService defines the unified contract for dispatching and tracking background tasks.
|
||||
type TaskService interface {
|
||||
Dispatch(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error)
|
||||
Retry(ctx context.Context, id uint64) (string, error)
|
||||
ListTasks() []TaskMetaDTO
|
||||
GetTaskMeta(taskType string) (TaskMetaDTO, bool)
|
||||
ValidatePayload(taskType string, payload []byte) ([]byte, error)
|
||||
ReloadScheduler() error
|
||||
AppendLog(ctx context.Context, format string, args ...any)
|
||||
ListExecutions(ctx context.Context, taskType, status string, page, pageSize int) ([]TaskExecutionDTO, int64, error)
|
||||
GetExecution(ctx context.Context, id uint64) (*TaskExecutionDTO, error)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// AdminListUsersFilter contains query parameters for filtering users in admin panel.
|
||||
type AdminListUsersFilter struct {
|
||||
Page int
|
||||
PageSize int
|
||||
UserID *uint64
|
||||
Username string
|
||||
Email string
|
||||
}
|
||||
|
||||
// AdminCreateUserRequest contains fields for admin to create a user.
|
||||
type AdminCreateUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
// AdminUpdateUserRequest contains fields for admin to update a user.
|
||||
type AdminUpdateUserRequest struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Password string `json:"password,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)
|
||||
|
||||
// GetUsersByIDs retrieves several users in one round-trip. An empty ids
|
||||
// slice yields no results and touches no storage.
|
||||
GetUsersByIDs(ctx context.Context, ids []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)
|
||||
|
||||
// AdminListUsers returns a filtered paginated list of users for admin management.
|
||||
AdminListUsers(ctx context.Context, filter AdminListUsersFilter) (int64, []*UserDTO, error)
|
||||
|
||||
// AdminGetUser retrieves complete user details by ID for admin management.
|
||||
AdminGetUser(ctx context.Context, id uint64) (*UserDTO, error)
|
||||
|
||||
// AdminCreateUser creates a user with admin specified options.
|
||||
AdminCreateUser(ctx context.Context, req AdminCreateUserRequest) (*UserDTO, error)
|
||||
|
||||
// AdminUpdateUser updates user details, email, nickname, admin role, and optional password.
|
||||
AdminUpdateUser(ctx context.Context, currentUserID uint64, req AdminUpdateUserRequest) error
|
||||
|
||||
// AdminUpdateUserStatus updates a user's active status (with admin protection).
|
||||
AdminUpdateUserStatus(ctx context.Context, id uint64, active bool) error
|
||||
|
||||
// AdminDeleteUser deletes a user (with self and admin protection, cascading tokens and accounts).
|
||||
AdminDeleteUser(ctx context.Context, currentUserID, targetID uint64) error
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
// 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]()
|
||||
errInterfaceType = reflect.TypeFor[error]()
|
||||
)
|
||||
|
||||
type eventListener struct {
|
||||
id uint64
|
||||
fnVal reflect.Value
|
||||
numIn int
|
||||
numOut int
|
||||
hasCtx bool
|
||||
hasPayload bool
|
||||
argType reflect.Type
|
||||
returnsErr bool
|
||||
returnsVal bool
|
||||
}
|
||||
|
||||
// EventBus is a thread-safe, strongly-typed in-process domain event bus supporting
|
||||
// Emit, Waterfall, Parallel, and Serial dispatch semantics.
|
||||
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) (T, error)
|
||||
// - func(ctx context.Context, event T) T
|
||||
// - func(event T) (T, error)
|
||||
// - func(event T) T
|
||||
// - 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))
|
||||
}
|
||||
|
||||
const maxHandlerReturnValues = 2
|
||||
numOut := fnType.NumOut()
|
||||
if numOut > maxHandlerReturnValues {
|
||||
panic(fmt.Sprintf("core/events: handler has %d return values, maximum 2 supported (value, error)", numOut))
|
||||
}
|
||||
|
||||
returnsErr := false
|
||||
returnsVal := false
|
||||
|
||||
switch numOut {
|
||||
case 1:
|
||||
out0 := fnType.Out(0)
|
||||
if out0.Implements(errInterfaceType) {
|
||||
returnsErr = true
|
||||
} else {
|
||||
returnsVal = true
|
||||
}
|
||||
case 2:
|
||||
out1 := fnType.Out(1)
|
||||
if !out1.Implements(errInterfaceType) {
|
||||
panic(fmt.Sprintf("core/events: second return value must be error, got %v", out1))
|
||||
}
|
||||
returnsVal = true
|
||||
returnsErr = true
|
||||
}
|
||||
|
||||
listener := eventListener{
|
||||
id: b.nextID.Add(1),
|
||||
fnVal: fnVal,
|
||||
numIn: numIn,
|
||||
numOut: numOut,
|
||||
returnsErr: returnsErr,
|
||||
returnsVal: returnsVal,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
listeners := b.getListeners(topic)
|
||||
if len(listeners) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
errIdx := l.numOut - 1
|
||||
if len(results) > errIdx && !results[errIdx].IsNil() {
|
||||
resErr = results[errIdx].Interface().(error)
|
||||
}
|
||||
}
|
||||
return resErr
|
||||
}()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Waterfall runs handlers sequentially as a transformation pipeline.
|
||||
// The returned value of each handler becomes the payload input for the next handler.
|
||||
// If any handler returns an error or panics, execution aborts immediately.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (b *EventBus) Waterfall(ctx context.Context, topic string, initialPayload any) (any, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
listeners := b.getListeners(topic)
|
||||
if len(listeners) == 0 {
|
||||
return initialPayload, nil
|
||||
}
|
||||
|
||||
currentPayload := initialPayload
|
||||
|
||||
for _, l := range listeners {
|
||||
var payloadVal reflect.Value
|
||||
if currentPayload != nil {
|
||||
payloadVal = reflect.ValueOf(currentPayload)
|
||||
}
|
||||
|
||||
args := b.buildArgs(ctx, l, payloadVal)
|
||||
|
||||
var stepVal any
|
||||
var stepErr error
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stepErr = fmt.Errorf("core/events: panic in waterfall handler for topic %q: %v", topic, r)
|
||||
}
|
||||
}()
|
||||
|
||||
results := l.fnVal.Call(args)
|
||||
if l.returnsErr {
|
||||
errIdx := l.numOut - 1
|
||||
if len(results) > errIdx && !results[errIdx].IsNil() {
|
||||
stepErr = results[errIdx].Interface().(error)
|
||||
}
|
||||
}
|
||||
if stepErr == nil && l.returnsVal && len(results) > 0 {
|
||||
stepVal = results[0].Interface()
|
||||
}
|
||||
}()
|
||||
|
||||
if stepErr != nil {
|
||||
return nil, stepErr
|
||||
}
|
||||
|
||||
if l.returnsVal {
|
||||
currentPayload = stepVal
|
||||
}
|
||||
}
|
||||
|
||||
return currentPayload, nil
|
||||
}
|
||||
|
||||
// Parallel executes all subscribers of the topic concurrently in separate goroutines.
|
||||
// It waits for all handlers to complete and collects any errors via errors.Join.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (b *EventBus) Parallel(ctx context.Context, topic string, payload any) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
listeners := b.getListeners(topic)
|
||||
if len(listeners) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payloadVal reflect.Value
|
||||
if payload != nil {
|
||||
payloadVal = reflect.ValueOf(payload)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(listeners))
|
||||
|
||||
for _, l := range listeners {
|
||||
wg.Add(1)
|
||||
go func(listener eventListener) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errCh <- fmt.Errorf("core/events: panic in parallel handler for topic %q: %v", topic, r)
|
||||
}
|
||||
}()
|
||||
|
||||
args := b.buildArgs(ctx, listener, payloadVal)
|
||||
results := listener.fnVal.Call(args)
|
||||
if listener.returnsErr {
|
||||
errIdx := listener.numOut - 1
|
||||
if len(results) > errIdx && !results[errIdx].IsNil() {
|
||||
errCh <- results[errIdx].Interface().(error)
|
||||
}
|
||||
}
|
||||
}(l)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
var errs []error
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Serial executes subscribers strictly in sequence.
|
||||
// If any subscriber returns an error or panics, execution stops immediately and returns that error.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func (b *EventBus) Serial(ctx context.Context, topic string, payload any) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
listeners := b.getListeners(topic)
|
||||
if len(listeners) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payloadVal reflect.Value
|
||||
if payload != nil {
|
||||
payloadVal = reflect.ValueOf(payload)
|
||||
}
|
||||
|
||||
for _, l := range listeners {
|
||||
args := b.buildArgs(ctx, l, payloadVal)
|
||||
|
||||
var stepErr error
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stepErr = fmt.Errorf("core/events: panic in serial handler for topic %q: %v", topic, r)
|
||||
}
|
||||
}()
|
||||
|
||||
results := l.fnVal.Call(args)
|
||||
if l.returnsErr {
|
||||
errIdx := l.numOut - 1
|
||||
if len(results) > errIdx && !results[errIdx].IsNil() {
|
||||
stepErr = results[errIdx].Interface().(error)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if stepErr != nil {
|
||||
return stepErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *EventBus) getListeners(topic string) []eventListener {
|
||||
b.mu.RLock()
|
||||
raw := b.handlers[topic]
|
||||
if len(raw) == 0 {
|
||||
b.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
listeners := make([]eventListener, len(raw))
|
||||
copy(listeners, raw)
|
||||
b.mu.RUnlock()
|
||||
return listeners
|
||||
}
|
||||
|
||||
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,395 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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 2 return values
|
||||
bus.On("test:invalid", func() (int, string, error) { return 0, "", nil })
|
||||
})
|
||||
|
||||
assert.Panics(t, func() {
|
||||
// 2 return values, but second is not error
|
||||
bus.On("test:invalid", func() (int, string) { 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))
|
||||
}
|
||||
|
||||
func TestEventBusWaterfall(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
// Handler 1: appends "-first"
|
||||
bus.On("pipeline:transform", func(ctx context.Context, s string) string {
|
||||
return s + "-first"
|
||||
})
|
||||
|
||||
// Handler 2: appends "-second" with error return
|
||||
bus.On("pipeline:transform", func(s string) (string, error) {
|
||||
return s + "-second", nil
|
||||
})
|
||||
|
||||
res, err := bus.Waterfall(context.Background(), "pipeline:transform", "init")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "init-first-second", res)
|
||||
|
||||
// Test short-circuit on error
|
||||
expectedErr := errors.New("waterfall step failed")
|
||||
bus.On("pipeline:error", func(s string) (string, error) {
|
||||
return s, expectedErr
|
||||
})
|
||||
bus.On("pipeline:error", func(s string) string {
|
||||
return s + "-should-not-run"
|
||||
})
|
||||
|
||||
res, err = bus.Waterfall(context.Background(), "pipeline:error", "start")
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
assert.Nil(t, res)
|
||||
}
|
||||
|
||||
func TestEventBusParallel(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
var counter atomic.Int64
|
||||
err1 := errors.New("parallel err 1")
|
||||
|
||||
bus.On("test:parallel", func(ctx context.Context, val int) error {
|
||||
counter.Add(int64(val))
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.On("test:parallel", func(val int) error {
|
||||
counter.Add(int64(val))
|
||||
return err1
|
||||
})
|
||||
|
||||
err := bus.Parallel(context.Background(), "test:parallel", 10)
|
||||
assert.ErrorIs(t, err, err1)
|
||||
assert.Equal(t, int64(20), counter.Load())
|
||||
}
|
||||
|
||||
func TestEventBusSerial(t *testing.T) {
|
||||
bus := core.NewEventBus()
|
||||
|
||||
var executed []int
|
||||
errStop := errors.New("serial stop")
|
||||
|
||||
bus.On("test:serial", func() error {
|
||||
executed = append(executed, 1)
|
||||
return nil
|
||||
})
|
||||
|
||||
bus.On("test:serial", func() error {
|
||||
executed = append(executed, 2)
|
||||
return errStop
|
||||
})
|
||||
|
||||
bus.On("test:serial", func() error {
|
||||
executed = append(executed, 3)
|
||||
return nil
|
||||
})
|
||||
|
||||
err := bus.Serial(context.Background(), "test:serial", nil)
|
||||
assert.ErrorIs(t, err, errStop)
|
||||
assert.Equal(t, []int{1, 2}, executed)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the configuration extension point.
|
||||
var (
|
||||
// ErrConfigConflict is returned when the same key is declared with disagreeing metadata.
|
||||
ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations")
|
||||
|
||||
// ErrConfigType is returned when a value cannot be converted to the declared type.
|
||||
ErrConfigType = errors.New("extpoints: configuration value type mismatch")
|
||||
|
||||
// ErrConfigUnknownKey is returned when a configuration key was never declared.
|
||||
ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key")
|
||||
|
||||
// ErrConfigNotResolved is returned when typed reads happen before resolution.
|
||||
ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first")
|
||||
|
||||
// ErrConfigTarget is returned when a binding target is not an addressable struct pointer.
|
||||
ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer")
|
||||
|
||||
// ErrConfigNoSource is returned when resolution is attempted without a registered source.
|
||||
ErrConfigNoSource = errors.New("extpoints: no configuration source registered")
|
||||
)
|
||||
|
||||
// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin.
|
||||
const (
|
||||
// OriginEnv marks a value that came from an environment variable.
|
||||
OriginEnv = "env"
|
||||
// OriginAutoEnable marks a boolean enabled by the presence of another environment variable.
|
||||
OriginAutoEnable = "auto-enable"
|
||||
// OriginFile marks a value that came from the configuration file.
|
||||
OriginFile = "file"
|
||||
// OriginDefault marks a value that came from a declaration default.
|
||||
OriginDefault = "default"
|
||||
)
|
||||
|
||||
// RedactedValue replaces the printed value of keys declared with secret:"true".
|
||||
const RedactedValue = "******"
|
||||
|
||||
// durationType distinguishes time.Duration from plain int64 during tag walking and decoding.
|
||||
var durationType = reflect.TypeFor[time.Duration]()
|
||||
|
||||
// ConfigRegistry must satisfy the full extension contract, so a missing accessor is a
|
||||
// compile error rather than a runtime surprise inside a plugin Apply.
|
||||
var _ ConfigExtension = (*ConfigRegistry)(nil)
|
||||
|
||||
// ConfigSource abstracts where raw configuration values come from, keeping the
|
||||
// micro-kernel free of concrete loaders such as viper.
|
||||
type ConfigSource interface {
|
||||
// Lookup returns the raw value stored at a dotted path in the configuration file.
|
||||
Lookup(path string) (any, bool)
|
||||
// LookupEnv returns the raw value of an environment variable.
|
||||
LookupEnv(name string) (string, bool)
|
||||
// Describe returns a human readable identity for the source, used in diagnostics.
|
||||
Describe() string
|
||||
}
|
||||
|
||||
// ConfigBinding declares that a plugin reads every `config` tagged field of Target
|
||||
// under a dotted configuration prefix.
|
||||
type ConfigBinding struct {
|
||||
// Prefix is the dotted configuration path, e.g. "redis". An empty prefix means
|
||||
// each field's `config` tag is already a full path.
|
||||
Prefix string
|
||||
// Target must be a non-nil pointer to a struct carrying `config` tags.
|
||||
Target any
|
||||
}
|
||||
|
||||
// configField is a single leaf discovered while walking a binding struct's tags.
|
||||
// key is the fully qualified dotted path used for resolution; path is the raw `config`
|
||||
// tag value used to locate the Go field again during Bind.
|
||||
type configField struct {
|
||||
key string
|
||||
path string
|
||||
env string
|
||||
autoEnable string
|
||||
def string
|
||||
secret bool
|
||||
typ reflect.Type
|
||||
}
|
||||
|
||||
// configDecl is the registered form of a configField, attributed to its declaring plugin.
|
||||
type configDecl struct {
|
||||
key string
|
||||
pluginID string
|
||||
env string
|
||||
autoEnable string
|
||||
def string
|
||||
secret bool
|
||||
typ reflect.Type
|
||||
}
|
||||
|
||||
// ConfigEntry is a redacted, self-describing view of one effective configuration key.
|
||||
type ConfigEntry struct {
|
||||
Key string
|
||||
PluginID string
|
||||
Env string
|
||||
Origin string
|
||||
Value string
|
||||
}
|
||||
|
||||
// ConfigView is the read-only surface over effective configuration values.
|
||||
// Keys are dotted paths such as "redis.enabled".
|
||||
type ConfigView interface {
|
||||
Value(key string) (any, bool)
|
||||
String(key, fallback string) string
|
||||
Bool(key string, fallback bool) bool
|
||||
Int(key string, fallback int) int
|
||||
Duration(key string, fallback time.Duration) time.Duration
|
||||
Strings(key string) []string
|
||||
WasSet(envName string) bool
|
||||
Origin(key string) string
|
||||
}
|
||||
|
||||
// ConfigExtension is the plugin-facing configuration extension point mounted on the
|
||||
// root Context and shared by every forked plugin scope.
|
||||
type ConfigExtension interface {
|
||||
ConfigView
|
||||
|
||||
// SetSource installs the raw value source after construction, letting the composition
|
||||
// root build the adapter once the kernel Context already exists.
|
||||
SetSource(src ConfigSource)
|
||||
// Declare registers plugin-owned configuration bindings before Apply runs.
|
||||
Declare(pluginID string, bindings ...ConfigBinding) error
|
||||
// Bind resolves and assigns the configuration values for a tagged struct.
|
||||
Bind(prefix string, target any) error
|
||||
// Resolve computes the effective value of every declared key once.
|
||||
Resolve() error
|
||||
// Resolved reports whether Resolve has already run.
|
||||
Resolved() bool
|
||||
// Entries returns the redacted effective configuration ordered by key.
|
||||
Entries() []ConfigEntry
|
||||
}
|
||||
|
||||
// ConfigRegistry implements ConfigExtension. Declarations are additive; values are
|
||||
// computed once by Resolve and reused by every later read.
|
||||
type ConfigRegistry struct {
|
||||
mu sync.RWMutex
|
||||
src ConfigSource
|
||||
decls map[string]*configDecl
|
||||
order []string
|
||||
values map[string]any
|
||||
origins map[string]string
|
||||
resolved bool
|
||||
}
|
||||
|
||||
// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so
|
||||
// that the kernel can construct the registry before the composition root injects one.
|
||||
func NewConfigRegistry(src ConfigSource) *ConfigRegistry {
|
||||
return &ConfigRegistry{
|
||||
src: src,
|
||||
decls: make(map[string]*configDecl),
|
||||
values: make(map[string]any),
|
||||
origins: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSource installs the raw value source. It is intended for the composition root,
|
||||
// which builds the adapter after the kernel Context already exists.
|
||||
func (r *ConfigRegistry) SetSource(src ConfigSource) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.src = src
|
||||
}
|
||||
|
||||
// Declare registers every `config` tagged leaf of each binding's target struct.
|
||||
// Repeated declarations of the same key are accepted only when their env, default,
|
||||
// auto-enable and secret metadata agree; disagreement is ErrConfigConflict.
|
||||
func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for _, b := range bindings {
|
||||
if err := r.declareBinding(pluginID, b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error {
|
||||
target, err := bindingStruct(b.Target, b.Prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fields, err := walkConfigFields(target.Type(), b.Prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range fields {
|
||||
if err := r.addDecl(pluginID, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindingStruct validates that a binding or bind target is a usable struct pointer.
|
||||
func bindingStruct(target any, prefix string) (reflect.Value, error) {
|
||||
rv := reflect.ValueOf(target)
|
||||
if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
|
||||
return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target)
|
||||
}
|
||||
return rv.Elem(), nil
|
||||
}
|
||||
|
||||
// walkConfigFields collects leaf configuration declarations from `config` tagged fields.
|
||||
// A field without a `config` tag is skipped, except for embedded structs which are
|
||||
// recursed into so their own tags resolve under the same prefix.
|
||||
func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) {
|
||||
var out []configField
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
sf := t.Field(i)
|
||||
if sf.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := sf.Tag.Get("config")
|
||||
if path == "-" {
|
||||
continue
|
||||
}
|
||||
if path == "" {
|
||||
if sf.Type.Kind() == reflect.Struct && sf.Type != durationType {
|
||||
nested, err := walkConfigFields(sf.Type, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, nested...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, configField{
|
||||
key: joinKey(prefix, path),
|
||||
path: path,
|
||||
env: sf.Tag.Get("env"),
|
||||
autoEnable: sf.Tag.Get("autoEnable"),
|
||||
def: sf.Tag.Get("default"),
|
||||
secret: strings.EqualFold(sf.Tag.Get("secret"), "true"),
|
||||
typ: sf.Type,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func joinKey(prefix, path string) string {
|
||||
if prefix == "" {
|
||||
return path
|
||||
}
|
||||
return prefix + "." + path
|
||||
}
|
||||
|
||||
// addDecl records one leaf, enforcing the shared-declaration consistency rule.
|
||||
func (r *ConfigRegistry) addDecl(pluginID string, f configField) error {
|
||||
if existing, ok := r.decls[f.key]; ok {
|
||||
if existing.env != f.env || existing.def != f.def ||
|
||||
existing.autoEnable != f.autoEnable || existing.secret != f.secret {
|
||||
return fmt.Errorf(
|
||||
"%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata",
|
||||
ErrConfigConflict, f.key, existing.pluginID, pluginID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
r.decls[f.key] = &configDecl{
|
||||
key: f.key, pluginID: pluginID, env: f.env,
|
||||
autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ,
|
||||
}
|
||||
r.order = append(r.order, f.key)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Resolve computes the effective value of every declared key. Priority is, in order:
|
||||
// an explicit environment override, an auto-enable trigger, the configuration file,
|
||||
// then the declared default. Resolution is idempotent; later declarations resolve lazily.
|
||||
func (r *ConfigRegistry) Resolve() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.src == nil {
|
||||
return ErrConfigNoSource
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, key := range r.order {
|
||||
if _, done := r.values[key]; done {
|
||||
continue
|
||||
}
|
||||
if err := r.resolveLocked(key); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
r.resolved = true
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Resolved reports whether Resolve has already run.
|
||||
func (r *ConfigRegistry) Resolved() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.resolved
|
||||
}
|
||||
|
||||
// resolveLocked computes the effective value of one key. The caller must hold r.mu.
|
||||
func (r *ConfigRegistry) resolveLocked(key string) error {
|
||||
d, ok := r.decls[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrConfigUnknownKey, key)
|
||||
}
|
||||
|
||||
if d.env != "" {
|
||||
if raw, found := r.src.LookupEnv(d.env); found {
|
||||
value, err := convertValue(raw, d.typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: key %q from environment %s: %w", ErrConfigType, key, d.env, err)
|
||||
}
|
||||
r.values[key], r.origins[key] = value, OriginEnv
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if d.autoEnable != "" && d.typ.Kind() == reflect.Bool {
|
||||
if _, found := r.src.LookupEnv(d.autoEnable); found {
|
||||
r.values[key], r.origins[key] = true, OriginAutoEnable
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if raw, found := r.src.Lookup(key); found {
|
||||
value, err := convertValue(raw, d.typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: key %q from %s: %w", ErrConfigType, key, r.src.Describe(), err)
|
||||
}
|
||||
r.values[key], r.origins[key] = value, OriginFile
|
||||
return nil
|
||||
}
|
||||
|
||||
if d.def != "" {
|
||||
value, err := convertValue(d.def, d.typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: default %q for key %q: %w", ErrConfigType, d.def, key, err)
|
||||
}
|
||||
r.values[key], r.origins[key] = value, OriginDefault
|
||||
return nil
|
||||
}
|
||||
|
||||
r.values[key] = reflect.New(d.typ).Elem().Interface()
|
||||
r.origins[key] = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bind resolves the tagged fields of target and assigns them in place. Prefixes that
|
||||
// were never declared self-register, so only gates need DeclareConfig.
|
||||
func (r *ConfigRegistry) Bind(prefix string, target any) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.src == nil {
|
||||
return ErrConfigNoSource
|
||||
}
|
||||
if !r.resolved {
|
||||
return fmt.Errorf("%w: Bind(%q, %T) ran before App.Prepare", ErrConfigNotResolved, prefix, target)
|
||||
}
|
||||
|
||||
elem, err := bindingStruct(target, prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields, err := walkConfigFields(elem.Type(), prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
if _, declared := r.decls[f.key]; !declared {
|
||||
if err := r.addDecl("bind:"+prefix, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, done := r.values[f.key]; !done {
|
||||
if err := r.resolveLocked(f.key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return assignFields(elem, fields, r.values)
|
||||
}
|
||||
|
||||
// assignFields writes resolved values into a freshly walked target struct.
|
||||
func assignFields(elem reflect.Value, fields []configField, values map[string]any) error {
|
||||
for _, f := range fields {
|
||||
field := elem.FieldByName(fieldNameForPath(elem.Type(), f.path))
|
||||
if !field.IsValid() || !field.CanSet() {
|
||||
return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key)
|
||||
}
|
||||
|
||||
value := reflect.ValueOf(values[f.key])
|
||||
if !value.Type().AssignableTo(field.Type()) {
|
||||
return fmt.Errorf("%w: key %q resolves to %s, field expects %s",
|
||||
ErrConfigType, f.key, value.Type(), field.Type())
|
||||
}
|
||||
field.Set(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Entries returns the effective configuration as redacted, key-sorted entries.
|
||||
func (r *ConfigRegistry) Entries() []ConfigEntry {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
keys := append([]string(nil), r.order...)
|
||||
sort.Strings(keys)
|
||||
|
||||
out := make([]ConfigEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
d := r.decls[key]
|
||||
if _, done := r.values[key]; !done && r.src != nil {
|
||||
_ = r.resolveLocked(key)
|
||||
}
|
||||
out = append(out, ConfigEntry{
|
||||
Key: d.key,
|
||||
PluginID: d.pluginID,
|
||||
Env: d.env,
|
||||
Origin: r.origins[key],
|
||||
Value: formatEntryValue(r.values[key], d.secret),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatEntryValue renders one effective value for diagnostics, masking secrets.
|
||||
func formatEntryValue(value any, secret bool) string {
|
||||
if secret {
|
||||
return RedactedValue
|
||||
}
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
// Value returns the resolved value for key, lazily resolving a declared key that has
|
||||
// not been computed yet. Missing and unresolvable keys report false rather than an
|
||||
// error so gates and diagnostics can keep using the fallback accessors.
|
||||
func (r *ConfigRegistry) Value(key string) (any, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, done := r.values[key]; !done {
|
||||
if r.src == nil {
|
||||
return nil, false
|
||||
}
|
||||
if _, declared := r.decls[key]; !declared {
|
||||
return nil, false
|
||||
}
|
||||
if err := r.resolveLocked(key); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
value, ok := r.values[key]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
// String returns the string value of key or fallback when absent or mismatched.
|
||||
func (r *ConfigRegistry) String(key, fallback string) string {
|
||||
if value, ok := r.Value(key); ok {
|
||||
if converted, err := convertString(value); err == nil {
|
||||
return converted.(string)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Bool returns the boolean value of key or fallback when absent or mismatched.
|
||||
func (r *ConfigRegistry) Bool(key string, fallback bool) bool {
|
||||
if value, ok := r.Value(key); ok {
|
||||
if converted, err := convertBool(value); err == nil {
|
||||
return converted.(bool)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Int returns the int value of key or fallback when absent or mismatched.
|
||||
func (r *ConfigRegistry) Int(key string, fallback int) int {
|
||||
if value, ok := r.Value(key); ok {
|
||||
converted, err := convertNumeric(value, reflect.TypeFor[int](), signedNumbers)
|
||||
if err == nil {
|
||||
return converted.(int)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Duration returns the time.Duration value of key or fallback when absent or mismatched.
|
||||
func (r *ConfigRegistry) Duration(key string, fallback time.Duration) time.Duration {
|
||||
if value, ok := r.Value(key); ok {
|
||||
if converted, err := convertDuration(value); err == nil {
|
||||
return converted.(time.Duration)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Strings returns the []string value of key, or nil when absent.
|
||||
func (r *ConfigRegistry) Strings(key string) []string {
|
||||
value, ok := r.Value(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
converted, err := convertSlice(value, reflect.TypeFor[[]string]())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
list, _ := converted.([]string)
|
||||
return list
|
||||
}
|
||||
|
||||
// WasSet reports whether an environment variable is present, regardless of its value.
|
||||
func (r *ConfigRegistry) WasSet(envName string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if r.src == nil {
|
||||
return false
|
||||
}
|
||||
_, found := r.src.LookupEnv(envName)
|
||||
return found
|
||||
}
|
||||
|
||||
// Origin reports where a key's effective value came from; "" means the zero value.
|
||||
func (r *ConfigRegistry) Origin(key string) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.origins[key]
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"Wavelet/core/extpoints"
|
||||
)
|
||||
|
||||
// fakeSource is an in-memory extpoints.ConfigSource used by configuration engine tests.
|
||||
type fakeSource struct {
|
||||
values map[string]any
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
func newFakeSource() *fakeSource {
|
||||
return &fakeSource{values: map[string]any{}, env: map[string]string{}}
|
||||
}
|
||||
|
||||
func (f *fakeSource) Lookup(path string) (any, bool) {
|
||||
v, ok := f.values[path]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (f *fakeSource) LookupEnv(name string) (string, bool) {
|
||||
v, ok := f.env[name]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (f *fakeSource) Describe() string { return "fake" }
|
||||
|
||||
// redisConfig mirrors how a plugin declares the configuration it reads.
|
||||
type redisConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
|
||||
Dial time.Duration `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"`
|
||||
Ignored string `config:"-"`
|
||||
private string
|
||||
}
|
||||
|
||||
func TestDeclareRegistersTaggedLeafKeys(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(newFakeSource())
|
||||
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
|
||||
keys := make([]string, 0)
|
||||
for _, e := range r.Entries() {
|
||||
keys = append(keys, e.Key)
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
"redis.addrs", "redis.db", "redis.dial_timeout", "redis.enabled", "redis.key_prefix",
|
||||
}, keys)
|
||||
}
|
||||
|
||||
func TestDeclareRejectsNonStructPointerTarget(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(newFakeSource())
|
||||
|
||||
assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: redisConfig{}}),
|
||||
extpoints.ErrConfigTarget)
|
||||
assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: (*redisConfig)(nil)}),
|
||||
extpoints.ErrConfigTarget)
|
||||
}
|
||||
|
||||
func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(newFakeSource())
|
||||
binding := extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}
|
||||
require.NoError(t, r.Declare("cache", binding))
|
||||
require.NoError(t, r.Declare("cache_memory", binding), "identical shared declarations must be allowed")
|
||||
|
||||
type conflictingConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ON" default:"true"`
|
||||
}
|
||||
err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "redis", Target: &conflictingConfig{}})
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigConflict)
|
||||
assert.Contains(t, err.Error(), "redis.enabled")
|
||||
assert.Contains(t, err.Error(), "cache")
|
||||
assert.Contains(t, err.Error(), "driver_http")
|
||||
}
|
||||
|
||||
// queueConfig is a composite element mirroring worker.queues in config.yaml.
|
||||
type queueConfig struct {
|
||||
Name string `config:"name"`
|
||||
Priority int `config:"priority"`
|
||||
}
|
||||
|
||||
type workerConfig struct {
|
||||
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"`
|
||||
Queues []queueConfig `config:"queues"`
|
||||
}
|
||||
|
||||
type sessionConfig struct {
|
||||
Secret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
Age int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
|
||||
}
|
||||
|
||||
func TestResolveScalarDurationAndSlice(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.values["redis.db"] = 1
|
||||
src.values["redis.dial_timeout"] = "5s"
|
||||
src.values["redis.addrs"] = []any{"127.0.0.1:6379"}
|
||||
src.env["REDIS_KEY_PREFIX"] = "refresh:"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
var got redisConfig
|
||||
require.NoError(t, r.Bind("redis", &got))
|
||||
assert.Equal(t, redisConfig{
|
||||
Addrs: []string{"127.0.0.1:6379"}, DB: 1, KeyPrefix: "refresh:", Dial: 5 * time.Second,
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestResolveFillsSliceFromScalarEnvironmentValue(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.env["REDIS_ADDR"] = "redis:6379"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
var got redisConfig
|
||||
require.NoError(t, r.Bind("redis", &got))
|
||||
assert.Equal(t, []string{"redis:6379"}, got.Addrs)
|
||||
}
|
||||
|
||||
func TestResolveCompositeSliceOfStructs(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.values["worker.concurrency"] = 20
|
||||
src.values["worker.queues"] = []any{
|
||||
map[string]any{"name": "webhook", "priority": 10},
|
||||
map[string]any{"name": "default", "priority": 3},
|
||||
}
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
var got workerConfig
|
||||
require.NoError(t, r.Bind("worker", &got))
|
||||
assert.Equal(t, workerConfig{
|
||||
Concurrency: 20,
|
||||
Queues: []queueConfig{{Name: "webhook", Priority: 10}, {Name: "default", Priority: 3}},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.env["WORKER_CONCURRENCY"] = "many"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
|
||||
|
||||
err := r.Resolve()
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigType)
|
||||
assert.Contains(t, err.Error(), "worker.concurrency")
|
||||
assert.Contains(t, err.Error(), "WORKER_CONCURRENCY")
|
||||
}
|
||||
|
||||
func TestViewAccessorsAndOrigins(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.values["redis.db"] = 1
|
||||
src.env["REDIS_ADDR"] = "redis:6379"
|
||||
src.env["REDIS_ENABLED"] = "false"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
assert.Equal(t, extpoints.OriginEnv, r.Origin("redis.addrs"))
|
||||
assert.Equal(t, []string{"redis:6379"}, r.Strings("redis.addrs"))
|
||||
assert.False(t, r.Bool("redis.enabled", true))
|
||||
assert.Equal(t, 1, r.Int("redis.db", 0))
|
||||
assert.Equal(t, "86400", r.String("app.session_age", "0"))
|
||||
assert.Equal(t, "fallback", r.String("redis.missing", "fallback"))
|
||||
assert.Zero(t, r.Duration("redis.dial_timeout", 0))
|
||||
assert.True(t, r.WasSet("REDIS_ADDR"))
|
||||
assert.False(t, r.WasSet("REDIS_NOPE"))
|
||||
}
|
||||
|
||||
func TestAutoEnableBeatsFileValueButLosesToExplicitEnv(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.env["REDIS_ADDR"] = "redis:6379"
|
||||
src.values["redis.enabled"] = false
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
assert.True(t, r.Bool("redis.enabled", false), "REDIS_ADDR presence implies enabled")
|
||||
assert.Equal(t, extpoints.OriginAutoEnable, r.Origin("redis.enabled"))
|
||||
|
||||
explicit := newFakeSource()
|
||||
explicit.env["REDIS_ADDR"] = "redis:6379"
|
||||
explicit.env["REDIS_ENABLED"] = "false"
|
||||
|
||||
r2 := extpoints.NewConfigRegistry(explicit)
|
||||
require.NoError(t, r2.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r2.Resolve())
|
||||
assert.False(t, r2.Bool("redis.enabled", true), "explicit REDIS_ENABLED must win over auto-enable")
|
||||
assert.Equal(t, extpoints.OriginEnv, r2.Origin("redis.enabled"))
|
||||
}
|
||||
|
||||
func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(newFakeSource())
|
||||
require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
entries := map[string]extpoints.ConfigEntry{}
|
||||
for _, e := range r.Entries() {
|
||||
entries[e.Key] = e
|
||||
}
|
||||
|
||||
assert.Equal(t, extpoints.RedactedValue, entries["app.session_secret"].Value)
|
||||
assert.Equal(t, extpoints.OriginDefault, entries["app.session_age"].Origin)
|
||||
assert.Equal(t, "86400", entries["app.session_age"].Value)
|
||||
}
|
||||
|
||||
func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(nil)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
|
||||
assert.ErrorIs(t, r.Resolve(), extpoints.ErrConfigNoSource)
|
||||
|
||||
var cfg redisConfig
|
||||
assert.ErrorIs(t, r.Bind("redis", &cfg), extpoints.ErrConfigNoSource)
|
||||
}
|
||||
|
||||
func TestBindRejectsReadsBeforeResolution(t *testing.T) {
|
||||
r := extpoints.NewConfigRegistry(newFakeSource())
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
|
||||
var cfg redisConfig
|
||||
err := r.Bind("redis", &cfg)
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigNotResolved)
|
||||
assert.Contains(t, err.Error(), "App.Prepare")
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// convertValue coerces a raw value coming from the configuration file or an
|
||||
// environment variable into the declared Go type.
|
||||
func convertValue(raw any, typ reflect.Type) (any, error) {
|
||||
if typ == durationType {
|
||||
return convertDuration(raw)
|
||||
}
|
||||
|
||||
switch typ.Kind() {
|
||||
case reflect.Bool:
|
||||
return convertBool(raw)
|
||||
case reflect.String:
|
||||
return convertString(raw)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return convertNumeric(raw, typ, signedNumbers)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return convertNumeric(raw, typ, unsignedNumbers)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return convertNumeric(raw, typ, floatingNumbers)
|
||||
case reflect.Slice:
|
||||
return convertSlice(raw, typ)
|
||||
case reflect.Struct:
|
||||
return convertStruct(raw, typ)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
|
||||
}
|
||||
}
|
||||
|
||||
func convertBool(raw any) (any, error) {
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
return v, nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q is not a boolean", ErrConfigType, v)
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %v is not a boolean", ErrConfigType, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func convertString(raw any) (any, error) {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprint(v), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %v is not a string", ErrConfigType, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// numericString extracts the textual form of a value so environment overrides,
|
||||
// which always arrive as strings, share one parsing path with file values.
|
||||
func numericString(raw any) (string, bool) {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v), true
|
||||
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprint(v), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// numericKind selects which strconv family converts a raw value.
|
||||
type numericKind int
|
||||
|
||||
const (
|
||||
signedNumbers numericKind = iota
|
||||
unsignedNumbers
|
||||
floatingNumbers
|
||||
)
|
||||
|
||||
// convertNumeric parses a raw value into the numeric type declared by typ. The three
|
||||
// numeric families share one implementation because they differ only in the strconv
|
||||
// call and the reflect setter.
|
||||
func convertNumeric(raw any, typ reflect.Type, family numericKind) (any, error) {
|
||||
text, ok := numericString(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %v is not a %s", ErrConfigType, raw, typ)
|
||||
}
|
||||
|
||||
out := reflect.New(typ).Elem()
|
||||
var err error
|
||||
|
||||
switch family {
|
||||
case signedNumbers:
|
||||
parsed, parseErr := strconv.ParseInt(text, 10, typ.Bits())
|
||||
out.SetInt(parsed)
|
||||
err = parseErr
|
||||
case unsignedNumbers:
|
||||
parsed, parseErr := strconv.ParseUint(text, 10, typ.Bits())
|
||||
out.SetUint(parsed)
|
||||
err = parseErr
|
||||
default:
|
||||
parsed, parseErr := strconv.ParseFloat(text, typ.Bits())
|
||||
out.SetFloat(parsed)
|
||||
err = parseErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
|
||||
}
|
||||
return out.Interface(), nil
|
||||
}
|
||||
|
||||
// convertDuration accepts both Go duration strings such as "200ms" and integer
|
||||
// nanoseconds, mirroring what the previous viper based decoding supported.
|
||||
func convertDuration(raw any) (any, error) {
|
||||
if v, ok := raw.(time.Duration); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
text, ok := numericString(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %v is not a duration", ErrConfigType, raw)
|
||||
}
|
||||
|
||||
if parsed, err := time.ParseDuration(text); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
nanos, err := strconv.ParseInt(text, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q is not a valid duration", ErrConfigType, text)
|
||||
}
|
||||
return time.Duration(nanos), nil
|
||||
}
|
||||
|
||||
// convertSlice promotes a scalar into a single-element slice so that a value such as
|
||||
// REDIS_ADDR=redis:6379 can populate the redis.addrs list.
|
||||
func convertSlice(raw any, typ reflect.Type) (any, error) {
|
||||
items, ok := sliceItems(raw)
|
||||
if !ok {
|
||||
items = []any{raw}
|
||||
}
|
||||
|
||||
out := reflect.MakeSlice(typ, 0, len(items))
|
||||
for _, item := range items {
|
||||
converted, err := convertValue(item, typ.Elem())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = reflect.Append(out, reflect.ValueOf(converted))
|
||||
}
|
||||
return out.Interface(), nil
|
||||
}
|
||||
|
||||
// sliceItems normalises the several slice shapes a loader may produce.
|
||||
func sliceItems(raw any) ([]any, bool) {
|
||||
switch v := raw.(type) {
|
||||
case []any:
|
||||
return v, true
|
||||
case []string:
|
||||
items := make([]any, len(v))
|
||||
for i, s := range v {
|
||||
items[i] = s
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(raw)
|
||||
if rv.IsValid() && rv.Kind() == reflect.Slice {
|
||||
items := make([]any, rv.Len())
|
||||
for i := range items {
|
||||
items[i] = rv.Index(i).Interface()
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func convertStruct(raw any, typ reflect.Type) (any, error) {
|
||||
table, ok := asStringMap(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %v is not a mapping, cannot decode into %s", ErrConfigType, raw, typ)
|
||||
}
|
||||
|
||||
fields, err := walkConfigFields(typ, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := reflect.New(typ).Elem()
|
||||
for _, f := range fields {
|
||||
item, present := table[f.path]
|
||||
if !present || item == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
converted, err := convertValue(item, f.typ)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s.%s: %w", ErrConfigType, typ.Name(), f.key, err)
|
||||
}
|
||||
out.FieldByName(fieldNameForPath(typ, f.path)).Set(reflect.ValueOf(converted))
|
||||
}
|
||||
return out.Interface(), nil
|
||||
}
|
||||
|
||||
// asStringMap normalises the two map shapes produced by YAML decoders.
|
||||
func asStringMap(raw any) (map[string]any, bool) {
|
||||
switch v := raw.(type) {
|
||||
case map[string]any:
|
||||
return v, true
|
||||
case map[any]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, val := range v {
|
||||
name, ok := key.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out[name] = val
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// fieldNameForPath maps a declared config path back to the Go struct field carrying it.
|
||||
func fieldNameForPath(t reflect.Type, path string) string {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
if t.Field(i).Tag.Get("config") == path {
|
||||
return t.Field(i).Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints_test
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/extpoints"
|
||||
"context"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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 TestRouterWhitelist(t *testing.T) {
|
||||
r := extpoints.NewRouterRegistry()
|
||||
require.NotNil(t, r)
|
||||
|
||||
r.RegisterWhitelist(
|
||||
"/healthz",
|
||||
"/api/v1/user/login",
|
||||
"/api/v1/oauth/*",
|
||||
)
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
api.RegisterWhitelist("/cap/challenge", "/cap/redeem")
|
||||
|
||||
whitelist := r.Whitelist()
|
||||
assert.Contains(t, whitelist, "/healthz")
|
||||
assert.Contains(t, whitelist, "/api/v1/user/login")
|
||||
assert.Contains(t, whitelist, "/api/v1/oauth/*")
|
||||
assert.Contains(t, whitelist, "/api/v1/cap/challenge")
|
||||
assert.Contains(t, whitelist, "/api/v1/cap/redeem")
|
||||
|
||||
// Exact match
|
||||
assert.True(t, r.IsWhitelisted("/healthz"))
|
||||
assert.True(t, r.IsWhitelisted("/api/v1/user/login"))
|
||||
assert.True(t, api.IsWhitelisted("/api/v1/cap/challenge"))
|
||||
|
||||
// Wildcard match
|
||||
assert.True(t, r.IsWhitelisted("/api/v1/oauth/sources"))
|
||||
assert.True(t, r.IsWhitelisted("/api/v1/oauth/github/authorize"))
|
||||
|
||||
// Non-whitelisted
|
||||
assert.False(t, r.IsWhitelisted("/api/v1/orders"))
|
||||
assert.False(t, r.IsWhitelisted("/api/v1/user/profile"))
|
||||
}
|
||||
|
||||
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"),
|
||||
extpoints.WithTaskType("cancel_timeout"),
|
||||
extpoints.WithTaskName("取消超时订单"),
|
||||
extpoints.WithTaskDescription("自动关单"),
|
||||
extpoints.WithTaskCategory("order"),
|
||||
extpoints.WithTaskSupportsTime(true),
|
||||
extpoints.WithTaskQueue("orders"),
|
||||
extpoints.WithTaskRetryable(true),
|
||||
nil, // test nil option
|
||||
)
|
||||
|
||||
// Re-register to test update
|
||||
tr.Register("order:cancel_timeout", handler,
|
||||
extpoints.WithTaskConcurrency(10),
|
||||
extpoints.WithTaskRetry(3),
|
||||
extpoints.WithTaskTimeout(10*time.Second),
|
||||
extpoints.WithTaskMetadata("queue", "high"),
|
||||
extpoints.WithTaskType("cancel_timeout"),
|
||||
extpoints.WithTaskName("取消超时订单"),
|
||||
extpoints.WithTaskDescription("自动关单"),
|
||||
extpoints.WithTaskCategory("order"),
|
||||
extpoints.WithTaskSupportsTime(true),
|
||||
extpoints.WithTaskQueue("orders"),
|
||||
extpoints.WithTaskRetryable(true),
|
||||
)
|
||||
|
||||
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"])
|
||||
assert.Equal(t, "cancel_timeout", tasks[0].Type)
|
||||
assert.Equal(t, "取消超时订单", tasks[0].Name)
|
||||
|
||||
dto := tasks[0].ToDTO()
|
||||
assert.Equal(t, "cancel_timeout", dto.Type)
|
||||
assert.Equal(t, "order:cancel_timeout", dto.AsynqTask)
|
||||
assert.Equal(t, "取消超时订单", dto.Name)
|
||||
assert.Equal(t, "取消超时订单", dto.DisplayName)
|
||||
assert.Equal(t, "自动关单", dto.Description)
|
||||
assert.Equal(t, "order", dto.Category)
|
||||
assert.True(t, dto.SupportsTime)
|
||||
assert.Equal(t, "orders", dto.Queue)
|
||||
assert.True(t, dto.Retryable)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func TestExtensionPointsUnregister(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
// 1. Router unregister
|
||||
rd := ctx.Router().GET("/temp", "temp_handler")
|
||||
assert.Greater(t, rd.ID, uint64(0))
|
||||
assert.Len(t, ctx.Router().Routes(), 1)
|
||||
assert.True(t, ctx.Router().Unregister("GET", "/temp"))
|
||||
assert.Len(t, ctx.Router().Routes(), 0)
|
||||
|
||||
rd2 := ctx.Router().POST("/temp2", "temp2_handler")
|
||||
assert.Len(t, ctx.Router().Routes(), 1)
|
||||
assert.True(t, ctx.Router().UnregisterByID(rd2.ID))
|
||||
assert.Len(t, ctx.Router().Routes(), 0)
|
||||
|
||||
// 2. Task unregister
|
||||
ctx.Task().Register("temp:task", "handler")
|
||||
assert.Len(t, ctx.Task().Tasks(), 1)
|
||||
assert.True(t, ctx.Task().Unregister("temp:task"))
|
||||
assert.Len(t, ctx.Task().Tasks(), 0)
|
||||
|
||||
// 3. Schedule unregister
|
||||
ctx.Schedule().RegisterCron("@hourly", "temp:cron", nil)
|
||||
assert.Len(t, ctx.Schedule().Schedules(), 1)
|
||||
assert.True(t, ctx.Schedule().Unregister("temp:cron"))
|
||||
assert.Len(t, ctx.Schedule().Schedules(), 0)
|
||||
|
||||
// 4. Setting unregister
|
||||
ctx.Settings().Register(extpoints.SettingSchema{Key: "temp.key", Default: 1})
|
||||
assert.Len(t, ctx.Settings().Schemas(), 1)
|
||||
assert.True(t, ctx.Settings().Unregister("temp.key"))
|
||||
assert.Len(t, ctx.Settings().Schemas(), 0)
|
||||
|
||||
// 5. Migration unregister
|
||||
fsys := fstest.MapFS{"001.sql": &fstest.MapFile{Data: []byte("-- migration")}}
|
||||
ctx.Migrations().Register("temp_plugin", fsys)
|
||||
assert.Len(t, ctx.Migrations().Entries(), 1)
|
||||
assert.True(t, ctx.Migrations().Unregister("temp_plugin"))
|
||||
assert.Len(t, ctx.Migrations().Entries(), 0)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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)
|
||||
Unregister(pluginID string) bool
|
||||
Entries() []MigrationEntry
|
||||
Get(pluginID string) (MigrationEntry, bool)
|
||||
}
|
||||
|
||||
// MigrationRegistry implements MigrationExtension.
|
||||
type MigrationRegistry struct {
|
||||
mu sync.RWMutex
|
||||
entries []MigrationEntry
|
||||
lookup map[string]MigrationEntry
|
||||
}
|
||||
|
||||
// NewMigrationRegistry creates a new MigrationRegistry.
|
||||
func NewMigrationRegistry() *MigrationRegistry {
|
||||
return &MigrationRegistry{
|
||||
lookup: make(map[string]MigrationEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers an embedded migration filesystem for a plugin.
|
||||
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
|
||||
}
|
||||
|
||||
// Unregister removes a registered migration entry by plugin ID.
|
||||
func (m *MigrationRegistry) Unregister(pluginID string) bool {
|
||||
return unregisterEntry(&m.mu, m.lookup, &m.entries, pluginID, func(e MigrationEntry) bool {
|
||||
return e.PluginID == pluginID
|
||||
})
|
||||
}
|
||||
|
||||
// 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,22 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func unregisterEntry[T any](mu *sync.RWMutex, lookup map[string]T, list *[]T, key string, matches func(T) bool) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if _, exists := lookup[key]; !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
delete(lookup, key)
|
||||
*list = slices.DeleteFunc(*list, matches)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
// 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 {
|
||||
ID uint64
|
||||
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, path string, handlers ...any) RouteDefinition
|
||||
// HandleRaw joins path with the group prefix but preserves a trailing slash,
|
||||
// so `/resource` and `/resource/` can coexist as distinct routes.
|
||||
HandleRaw(method, path string, handlers ...any) RouteDefinition
|
||||
// BasePath reports this group's absolute prefix ("" for the root registry).
|
||||
BasePath() string
|
||||
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
|
||||
Unregister(method, path string) bool
|
||||
UnregisterByID(id uint64) bool
|
||||
RegisterWhitelist(patterns ...string)
|
||||
Whitelist() []string
|
||||
IsWhitelisted(path string) bool
|
||||
}
|
||||
|
||||
// RouterRegistry implements RouterExtension as the root route and middleware collector.
|
||||
type RouterRegistry struct {
|
||||
mu sync.RWMutex
|
||||
nextID uint64
|
||||
routes []RouteDefinition
|
||||
middlewares []any
|
||||
whitelist PathWhitelist
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return r.addRoute(method, cleanPath(path), handlers...)
|
||||
}
|
||||
|
||||
// addRoute appends a route whose path is already normalised.
|
||||
func (r *RouterRegistry) addRoute(method, fullPath string, handlers ...any) RouteDefinition {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.nextID++
|
||||
rd := RouteDefinition{
|
||||
ID: r.nextID,
|
||||
Method: strings.ToUpper(method),
|
||||
Path: fullPath,
|
||||
Handlers: handlers,
|
||||
Middlewares: append([]any(nil), r.middlewares...),
|
||||
}
|
||||
r.routes = append(r.routes, rd)
|
||||
return rd
|
||||
}
|
||||
|
||||
// Unregister removes a route matching method and path from the registry.
|
||||
func (r *RouterRegistry) Unregister(method, path string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
targetMethod := strings.ToUpper(method)
|
||||
targetPath := cleanPath(path)
|
||||
|
||||
for i, rd := range r.routes {
|
||||
if rd.Method == targetMethod && rd.Path == targetPath {
|
||||
r.routes = append(r.routes[:i], r.routes[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UnregisterByID removes a route by its unique ID.
|
||||
func (r *RouterRegistry) UnregisterByID(id uint64) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for i, rd := range r.routes {
|
||||
if rd.ID == id {
|
||||
r.routes = append(r.routes[:i], r.routes[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// RegisterWhitelist adds path patterns to the whitelist.
|
||||
func (r *RouterRegistry) RegisterWhitelist(patterns ...string) {
|
||||
r.whitelist.Add(patterns...)
|
||||
}
|
||||
|
||||
// Whitelist returns a copy of all registered whitelist path patterns.
|
||||
func (r *RouterRegistry) Whitelist() []string {
|
||||
return r.whitelist.Patterns()
|
||||
}
|
||||
|
||||
// IsWhitelisted checks if the given path matches any registered whitelist pattern.
|
||||
func (r *RouterRegistry) IsWhitelisted(path string) bool {
|
||||
return r.whitelist.Match(path)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return g.addRoute(method, joinPaths(g.prefix, path), handlers...)
|
||||
}
|
||||
|
||||
// addRoute appends a route under this group whose path is already joined.
|
||||
func (g *RouterGroup) addRoute(method, fullPath string, handlers ...any) RouteDefinition {
|
||||
g.registry.mu.Lock()
|
||||
defer g.registry.mu.Unlock()
|
||||
|
||||
allMiddlewares := make([]any, 0, len(g.registry.middlewares)+len(g.middlewares))
|
||||
allMiddlewares = append(allMiddlewares, g.registry.middlewares...)
|
||||
allMiddlewares = append(allMiddlewares, g.middlewares...)
|
||||
|
||||
g.registry.nextID++
|
||||
rd := RouteDefinition{
|
||||
ID: g.registry.nextID,
|
||||
Method: strings.ToUpper(method),
|
||||
Path: fullPath,
|
||||
Handlers: handlers,
|
||||
Middlewares: allMiddlewares,
|
||||
}
|
||||
g.registry.routes = append(g.registry.routes, rd)
|
||||
return rd
|
||||
}
|
||||
|
||||
// Unregister removes a route under this group prefix matching method and path.
|
||||
func (g *RouterGroup) Unregister(method, path string) bool {
|
||||
fullPath := joinPaths(g.prefix, path)
|
||||
return g.registry.Unregister(method, fullPath)
|
||||
}
|
||||
|
||||
// UnregisterByID removes a route by its unique ID.
|
||||
func (g *RouterGroup) UnregisterByID(id uint64) bool {
|
||||
return g.registry.UnregisterByID(id)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// RegisterWhitelist adds path patterns under this group prefix to the whitelist.
|
||||
func (g *RouterGroup) RegisterWhitelist(patterns ...string) {
|
||||
for _, p := range patterns {
|
||||
g.registry.RegisterWhitelist(joinPaths(g.prefix, p))
|
||||
}
|
||||
}
|
||||
|
||||
// Whitelist returns a copy of all registered whitelist path patterns.
|
||||
func (g *RouterGroup) Whitelist() []string {
|
||||
return g.registry.Whitelist()
|
||||
}
|
||||
|
||||
// IsWhitelisted checks if the given path matches any registered whitelist pattern.
|
||||
func (g *RouterGroup) IsWhitelisted(path string) bool {
|
||||
return g.registry.IsWhitelisted(path)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// MatchPathPattern checks if a URL path matches a pattern (supports exact match and wildcards).
|
||||
func MatchPathPattern(pattern, path string) bool {
|
||||
pattern = cleanPath(pattern)
|
||||
path = cleanPath(path)
|
||||
|
||||
if pattern == path {
|
||||
return true
|
||||
}
|
||||
|
||||
// Suffix wildcard: /api/v1/oauth/* matches /api/v1/oauth and /api/v1/oauth/...
|
||||
if strings.HasSuffix(pattern, "/*") {
|
||||
prefix := strings.TrimSuffix(pattern, "/*")
|
||||
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Parameter wildcard: /api/v1/oauth/*/authorize or /api/v1/oauth/:source/authorize
|
||||
patternParts := strings.Split(pattern, "/")
|
||||
pathParts := strings.Split(path, "/")
|
||||
if len(patternParts) == len(pathParts) {
|
||||
matched := true
|
||||
for i, part := range patternParts {
|
||||
if part == "*" || strings.HasPrefix(part, ":") {
|
||||
continue
|
||||
}
|
||||
if part != pathParts[i] {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// compiledPattern holds a whitelist pattern with its per-request work already done.
|
||||
type compiledPattern struct {
|
||||
raw string // normalised pattern, reported back by Patterns
|
||||
prefix string // non-empty when the pattern ends in "/*"
|
||||
parts []string // normalised pattern split on "/"
|
||||
}
|
||||
|
||||
// PathWhitelist matches request paths against a fixed set of patterns.
|
||||
//
|
||||
// Patterns are registered once during plugin Apply and never change afterwards, so
|
||||
// normalising and splitting them on every request is wasted work. PathWhitelist
|
||||
// does that once at registration instead. The zero value is ready to use.
|
||||
type PathWhitelist struct {
|
||||
mu sync.RWMutex
|
||||
patterns []compiledPattern
|
||||
}
|
||||
|
||||
// NewPathWhitelist returns a whitelist pre-populated with the given patterns.
|
||||
func NewPathWhitelist(patterns ...string) *PathWhitelist {
|
||||
w := &PathWhitelist{}
|
||||
w.Add(patterns...)
|
||||
return w
|
||||
}
|
||||
|
||||
// compilePatterns normalises and splits each pattern once, ahead of any request.
|
||||
func compilePatterns(patterns []string) []compiledPattern {
|
||||
compiled := make([]compiledPattern, 0, len(patterns))
|
||||
for _, p := range patterns {
|
||||
clean := cleanPath(p)
|
||||
cp := compiledPattern{raw: clean, parts: strings.Split(clean, "/")}
|
||||
if strings.HasSuffix(clean, "/*") {
|
||||
cp.prefix = strings.TrimSuffix(clean, "/*")
|
||||
}
|
||||
compiled = append(compiled, cp)
|
||||
}
|
||||
return compiled
|
||||
}
|
||||
|
||||
// Add appends patterns, normalising and splitting each now rather than per request.
|
||||
func (w *PathWhitelist) Add(patterns ...string) {
|
||||
if len(patterns) == 0 {
|
||||
return
|
||||
}
|
||||
compiled := compilePatterns(patterns)
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.patterns = append(w.patterns, compiled...)
|
||||
}
|
||||
|
||||
// Replace discards any existing patterns and installs the given ones, for callers
|
||||
// whose configuration is a full swap rather than an incremental registration.
|
||||
func (w *PathWhitelist) Replace(patterns ...string) {
|
||||
compiled := compilePatterns(patterns)
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.patterns = compiled
|
||||
}
|
||||
|
||||
// Match reports whether path matches any registered pattern. Equivalent to calling
|
||||
// MatchPathPattern for every pattern, except the path is normalised and split once.
|
||||
func (w *PathWhitelist) Match(path string) bool {
|
||||
clean := cleanPath(path)
|
||||
pathParts := strings.Split(clean, "/")
|
||||
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
for i := range w.patterns {
|
||||
p := &w.patterns[i]
|
||||
if p.raw == clean {
|
||||
return true
|
||||
}
|
||||
// A suffix wildcard matches both the bare prefix and anything below it.
|
||||
if p.prefix != "" && (clean == p.prefix || strings.HasPrefix(clean, p.prefix+"/")) {
|
||||
return true
|
||||
}
|
||||
if len(p.parts) != len(pathParts) {
|
||||
continue
|
||||
}
|
||||
if matchSegments(p.parts, pathParts) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchSegments compares an already-split pattern against an already-split path.
|
||||
func matchSegments(patternParts, pathParts []string) bool {
|
||||
for i, part := range patternParts {
|
||||
if part == "*" || strings.HasPrefix(part, ":") {
|
||||
continue
|
||||
}
|
||||
if part != pathParts[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Patterns returns a copy of the registered patterns in registration order.
|
||||
func (w *PathWhitelist) Patterns() []string {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
res := make([]string, len(w.patterns))
|
||||
for i := range w.patterns {
|
||||
res[i] = w.patterns[i].raw
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ─── Raw path registration ────────────────────────────────────────────────────
|
||||
|
||||
// ensureLeadingSlash normalises a path to start with exactly one "/" while
|
||||
// preserving any trailing slash (unlike cleanPath).
|
||||
func ensureLeadingSlash(p string) string {
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
return "/" + p
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// joinPathPreservingTrailing joins a group prefix and a relative path without
|
||||
// stripping a trailing slash, so a group "/x" can serve both "/x" and "/x/".
|
||||
func joinPathPreservingTrailing(base, relative string) string {
|
||||
rel := ensureLeadingSlash(relative)
|
||||
if base == "" || base == "/" {
|
||||
return rel
|
||||
}
|
||||
return strings.TrimSuffix(cleanPath(base), "/") + rel
|
||||
}
|
||||
|
||||
// HandleRaw registers a route on the root registry, preserving a trailing slash.
|
||||
func (r *RouterRegistry) HandleRaw(method, path string, handlers ...any) RouteDefinition {
|
||||
return r.addRoute(method, ensureLeadingSlash(path), handlers...)
|
||||
}
|
||||
|
||||
// BasePath returns "" because the root registry has no prefix.
|
||||
func (r *RouterRegistry) BasePath() string { return "" }
|
||||
|
||||
// HandleRaw registers a route under this group, preserving a trailing slash.
|
||||
func (g *RouterGroup) HandleRaw(method, path string, handlers ...any) RouteDefinition {
|
||||
return g.addRoute(method, joinPathPreservingTrailing(g.prefix, path), handlers...)
|
||||
}
|
||||
|
||||
// BasePath returns this group's absolute prefix.
|
||||
func (g *RouterGroup) BasePath() string { return g.prefix }
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestHandleRawPreservesTrailingSlash 验证 HandleRaw 能表达 /x 与 /x/ 两条不同路由,
|
||||
// 而 Handle 会归一化掉尾部斜杠(server 插件的 list 端点历史行为依赖这一点)。
|
||||
func TestHandleRawPreservesTrailingSlash(t *testing.T) {
|
||||
r := &RouterRegistry{}
|
||||
g := r.Group("/api/v1/nodes")
|
||||
|
||||
if got := g.BasePath(); got != "/api/v1/nodes" {
|
||||
t.Fatalf("BasePath() = %q, want %q", got, "/api/v1/nodes")
|
||||
}
|
||||
slashless := g.Handle("GET", "")
|
||||
slashed := g.HandleRaw("GET", "/")
|
||||
|
||||
if slashless.Path != "/api/v1/nodes" {
|
||||
t.Errorf("Handle(\"\") path = %q, want %q", slashless.Path, "/api/v1/nodes")
|
||||
}
|
||||
if slashed.Path != "/api/v1/nodes/" {
|
||||
t.Errorf("HandleRaw(\"/\") path = %q, want %q", slashed.Path, "/api/v1/nodes/")
|
||||
}
|
||||
if slashed.ID == slashless.ID {
|
||||
t.Error("HandleRaw must allocate its own route ID so scoped teardown can unregister both")
|
||||
}
|
||||
if got := len(r.Routes()); got != 2 {
|
||||
t.Errorf("registry routes = %d, want 2", got)
|
||||
}
|
||||
if !r.UnregisterByID(slashed.ID) {
|
||||
t.Error("UnregisterByID(HandleRaw route) = false, want true")
|
||||
}
|
||||
if got := len(r.Routes()); got != 1 {
|
||||
t.Errorf("routes after unregister = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryHandleRawKeepsAbsolutePath 根注册表上 HandleRaw 只做绝对化处理。
|
||||
func TestRegistryHandleRawKeepsAbsolutePath(t *testing.T) {
|
||||
r := &RouterRegistry{}
|
||||
if got := r.HandleRaw("GET", "/health/").Path; got != "/health/" {
|
||||
t.Errorf("path = %q, want %q", got, "/health/")
|
||||
}
|
||||
if got := r.HandleRaw("POST", "submit").Path; got != "/submit" {
|
||||
t.Errorf("path = %q, want %q", got, "/submit")
|
||||
}
|
||||
if got := r.BasePath(); got != "" {
|
||||
t.Errorf("registry BasePath() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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, taskType string, payload any, opts ...ScheduleOption)
|
||||
RegisterCron(spec, taskType string, payload any, opts ...ScheduleOption)
|
||||
Schedules() []ScheduleDefinition
|
||||
Get(taskType string) (ScheduleDefinition, bool)
|
||||
Unregister(taskType string) 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, 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, taskType string, payload any, opts ...ScheduleOption) {
|
||||
s.Register(spec, taskType, payload, opts...)
|
||||
}
|
||||
|
||||
// Unregister removes a registered schedule definition by its task type.
|
||||
func (s *ScheduleRegistry) Unregister(taskType string) bool {
|
||||
return unregisterEntry(&s.mu, s.lookup, &s.schedules, taskType, func(item ScheduleDefinition) bool {
|
||||
return item.TaskType == taskType
|
||||
})
|
||||
}
|
||||
|
||||
// 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,88 @@
|
||||
// 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)
|
||||
Unregister(key string) 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
|
||||
}
|
||||
|
||||
// Unregister removes a registered SettingSchema by its key.
|
||||
func (s *SettingRegistry) Unregister(key string) bool {
|
||||
return unregisterEntry(&s.mu, s.lookup, &s.schemas, key, func(item SettingSchema) bool {
|
||||
return item.Key == key
|
||||
})
|
||||
}
|
||||
|
||||
// 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,285 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TaskDefinition holds the definition and runtime options for an asynchronous background task.
|
||||
type TaskDefinition struct {
|
||||
Pattern string
|
||||
Type string
|
||||
Name string
|
||||
DisplayName string
|
||||
Description string
|
||||
Category string
|
||||
SupportsTime bool
|
||||
Retryable bool
|
||||
Queue string
|
||||
Params []contracts.TaskParamDTO
|
||||
Handler any
|
||||
Concurrency int
|
||||
Retry int
|
||||
Timeout time.Duration
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// TaskOption configures a TaskDefinition.
|
||||
type TaskOption func(*TaskDefinition)
|
||||
|
||||
// WithTaskType sets the admin task type identifier.
|
||||
func WithTaskType(taskType string) TaskOption {
|
||||
return func(td *TaskDefinition) {
|
||||
td.Type = taskType
|
||||
}
|
||||
}
|
||||
|
||||
// WithTaskName sets the task human-readable display name.
|
||||
func WithTaskName(name string) TaskOption {
|
||||
return func(td *TaskDefinition) {
|
||||
td.Name = name
|
||||
if td.DisplayName == "" {
|
||||
td.DisplayName = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTaskDisplayName sets the task display name.
|
||||
func WithTaskDisplayName(displayName string) TaskOption {
|
||||
return func(td *TaskDefinition) {
|
||||
td.DisplayName = displayName
|
||||
if td.Name == "" {
|
||||
td.Name = displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTaskDescription sets the task description.
|
||||
func WithTaskDescription(desc string) TaskOption {
|
||||
return func(td *TaskDefinition) {
|
||||
td.Description = desc
|
||||
}
|
||||
}
|
||||
|
||||
// WithTaskCategory sets the task category grouping.
|
||||
func WithTaskCategory(category string) TaskOption {
|
||||
return func(td *TaskDefinition) {
|
||||
td.Category = category
|
||||
}
|
||||
}
|
||||
|
||||
// WithTaskSupportsTime sets whether the task supports time range filtering.
|
||||
func WithTaskSupportsTime(supports bool) TaskOption {
|
||||
return func(td *TaskDefinition) {
|
||||
td.SupportsTime = supports
|
||||
}
|
||||
}
|
||||
|
||||
// WithTaskQueue sets the task queue.
|
||||
func WithTaskQueue(queue string) TaskOption {
|
||||
return func(td *TaskDefinition) {
|
||||
td.Queue = queue
|
||||
}
|
||||
}
|
||||
|
||||
// WithTaskRetryable sets whether the task is retryable.
|
||||
func WithTaskRetryable(retryable bool) TaskOption {
|
||||
return func(td *TaskDefinition) {
|
||||
td.Retryable = retryable
|
||||
}
|
||||
}
|
||||
|
||||
// WithTaskParams sets the task parameter definitions.
|
||||
func WithTaskParams(params ...contracts.TaskParamDTO) TaskOption {
|
||||
return func(td *TaskDefinition) {
|
||||
td.Params = append(td.Params, params...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithTaskMeta sets all task metadata from a TaskMetaDTO.
|
||||
func WithTaskMeta(meta contracts.TaskMetaDTO) TaskOption {
|
||||
return func(td *TaskDefinition) {
|
||||
if meta.Type != "" {
|
||||
td.Type = meta.Type
|
||||
}
|
||||
if meta.Name != "" {
|
||||
td.Name = meta.Name
|
||||
}
|
||||
if meta.DisplayName != "" {
|
||||
td.DisplayName = meta.DisplayName
|
||||
}
|
||||
if meta.Description != "" {
|
||||
td.Description = meta.Description
|
||||
}
|
||||
if meta.Category != "" {
|
||||
td.Category = meta.Category
|
||||
}
|
||||
td.SupportsTime = meta.SupportsTime
|
||||
if meta.Queue != "" {
|
||||
td.Queue = meta.Queue
|
||||
}
|
||||
td.Retryable = meta.Retryable
|
||||
if meta.MaxRetry > 0 {
|
||||
td.Retry = meta.MaxRetry
|
||||
}
|
||||
if meta.Timeout > 0 {
|
||||
td.Timeout = meta.Timeout
|
||||
}
|
||||
if len(meta.Params) > 0 {
|
||||
td.Params = append([]contracts.TaskParamDTO(nil), meta.Params...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// ToDTO converts TaskDefinition to contracts.TaskMetaDTO.
|
||||
func (td TaskDefinition) ToDTO() contracts.TaskMetaDTO {
|
||||
taskType := td.Type
|
||||
if taskType == "" {
|
||||
taskType = td.Pattern
|
||||
}
|
||||
name := td.Name
|
||||
if name == "" {
|
||||
name = td.DisplayName
|
||||
}
|
||||
if name == "" {
|
||||
name = td.Pattern
|
||||
}
|
||||
displayName := td.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = name
|
||||
}
|
||||
queue := td.Queue
|
||||
if queue == "" {
|
||||
queue = "default"
|
||||
}
|
||||
retryable := td.Retryable
|
||||
if !retryable && td.Retry > 0 {
|
||||
retryable = true
|
||||
}
|
||||
return contracts.TaskMetaDTO{
|
||||
Type: taskType,
|
||||
AsynqTask: td.Pattern,
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
Description: td.Description,
|
||||
Category: td.Category,
|
||||
SupportsTime: td.SupportsTime,
|
||||
Params: td.Params,
|
||||
MaxRetry: td.Retry,
|
||||
Timeout: td.Timeout,
|
||||
Queue: queue,
|
||||
Retryable: retryable,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
Unregister(pattern string) 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
|
||||
}
|
||||
|
||||
// Unregister removes a registered task definition by its pattern.
|
||||
func (t *TaskRegistry) Unregister(pattern string) bool {
|
||||
return unregisterEntry(&t.mu, t.lookup, &t.tasks, pattern, func(item TaskDefinition) bool {
|
||||
return item.Pattern == pattern
|
||||
})
|
||||
}
|
||||
|
||||
// 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,157 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints_test
|
||||
|
||||
import (
|
||||
"Wavelet/core/extpoints"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// whitelistEquivalencePatterns and paths cover every matching rule MatchPathPattern
|
||||
// implements, so PathWhitelist.Match can be pinned against the behaviour it replaces.
|
||||
var (
|
||||
whitelistEquivalencePatterns = []string{
|
||||
"/api/v1/user/login",
|
||||
"/api/v1/oauth/*",
|
||||
"/api/v1/cap/:source/authorize",
|
||||
"/api/v1/files/*/download",
|
||||
"/",
|
||||
"login",
|
||||
"/api/v1/x/",
|
||||
"",
|
||||
}
|
||||
|
||||
whitelistEquivalencePaths = []string{
|
||||
"/api/v1/user/login",
|
||||
"/api/v1/user/login/",
|
||||
"/api/v1/oauth/callback",
|
||||
"/api/v1/oauth",
|
||||
"/api/v1/oauth/a/b",
|
||||
"/api/v1/cap/github/authorize",
|
||||
"/api/v1/cap/:source/authorize",
|
||||
"/api/v1/files/abc/download",
|
||||
"/api/v1/files/a/b/download",
|
||||
"/",
|
||||
"login",
|
||||
"/login",
|
||||
"",
|
||||
"/api/v1/x",
|
||||
"/api/v1/x/y",
|
||||
}
|
||||
)
|
||||
|
||||
// legacyMatch reproduces the per-request loop every whitelist caller used before
|
||||
// PathWhitelist existed.
|
||||
func legacyMatch(patterns []string, path string) bool {
|
||||
for _, pattern := range patterns {
|
||||
if extpoints.MatchPathPattern(pattern, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestPathWhitelistMatchesLegacyLoop(t *testing.T) {
|
||||
for _, pattern := range whitelistEquivalencePatterns {
|
||||
wl := extpoints.NewPathWhitelist(pattern)
|
||||
for _, path := range whitelistEquivalencePaths {
|
||||
got := wl.Match(path)
|
||||
want := legacyMatch([]string{pattern}, path)
|
||||
if got != want {
|
||||
t.Errorf("pattern %q path %q: Match=%v, legacy=%v", pattern, path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathWhitelistAccumulatesAcrossRegistration(t *testing.T) {
|
||||
wl := extpoints.NewPathWhitelist("/api/v1/a")
|
||||
wl.Add("/api/v1/b/*")
|
||||
|
||||
if !wl.Match("/api/v1/a") {
|
||||
t.Error("first registration lost")
|
||||
}
|
||||
if !wl.Match("/api/v1/b/deep") {
|
||||
t.Error("second registration lost")
|
||||
}
|
||||
if wl.Match("/api/v1/c") {
|
||||
t.Error("path outside both registrations matched")
|
||||
}
|
||||
|
||||
got := wl.Patterns()
|
||||
want := []string{"/api/v1/a", "/api/v1/b/*"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("Patterns() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("Patterns()[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathWhitelistReplaceDropsPreviousPatterns(t *testing.T) {
|
||||
wl := extpoints.NewPathWhitelist("/api/v1/a")
|
||||
wl.Replace("/api/v1/b")
|
||||
|
||||
if wl.Match("/api/v1/a") {
|
||||
t.Error("Replace kept a pattern it should have discarded")
|
||||
}
|
||||
if !wl.Match("/api/v1/b") {
|
||||
t.Error("Replace did not install the new pattern")
|
||||
}
|
||||
}
|
||||
|
||||
// whitelistBenchPatterns mirrors a realistically sized auth whitelist.
|
||||
var whitelistBenchPatterns = []string{
|
||||
"/api/v1/user/login",
|
||||
"/api/v1/auth/refresh",
|
||||
"/api/v1/oauth/*",
|
||||
"/api/v1/cap/*",
|
||||
"/api/v1/public/config",
|
||||
"/api/v1/health",
|
||||
"/api/v1/uploads/:id/file",
|
||||
"/api/v1/notify/webhook/:channel",
|
||||
"/login",
|
||||
"/api/v1/access-tokens/:id/revoke",
|
||||
}
|
||||
|
||||
// TestPathWhitelistAllocationReduction asserts the point of pre-compiling: a single
|
||||
// Match must allocate less than the legacy per-pattern loop it replaces.
|
||||
func TestPathWhitelistAllocationReduction(t *testing.T) {
|
||||
wl := extpoints.NewPathWhitelist(whitelistBenchPatterns...)
|
||||
|
||||
legacy := testing.Benchmark(func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_ = legacyMatch(whitelistBenchPatterns, "/api/v1/uploads/9/file")
|
||||
}
|
||||
})
|
||||
compiled := testing.Benchmark(func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_ = wl.Match("/api/v1/uploads/9/file")
|
||||
}
|
||||
})
|
||||
|
||||
legacyAlloc := legacy.AllocsPerOp()
|
||||
compiledAlloc := compiled.AllocsPerOp()
|
||||
t.Logf("legacy %d allocs/op, PathWhitelist %d allocs/op", legacyAlloc, compiledAlloc)
|
||||
|
||||
if compiledAlloc >= legacyAlloc {
|
||||
t.Errorf("PathWhitelist allocated %d/op, want fewer than legacy %d/op", compiledAlloc, legacyAlloc)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLegacyWhitelistMatch(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_ = legacyMatch(whitelistBenchPatterns, "/api/v1/uploads/9/file")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPathWhitelistMatch(b *testing.B) {
|
||||
wl := extpoints.NewPathWhitelist(whitelistBenchPatterns...)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_ = wl.Match("/api/v1/uploads/9/file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FiberState represents the lifecycle status of a plugin instance in the Cordis micro-kernel.
|
||||
type FiberState string
|
||||
|
||||
const (
|
||||
// FiberPending indicates the plugin is waiting for its required dependencies to be provided.
|
||||
FiberPending FiberState = "PENDING"
|
||||
|
||||
// FiberLoading indicates the plugin is currently running its Apply mounting phase.
|
||||
FiberLoading FiberState = "LOADING"
|
||||
|
||||
// FiberActive indicates the plugin is fully mounted, active, and operating without error.
|
||||
FiberActive FiberState = "ACTIVE"
|
||||
|
||||
// FiberUnloading indicates the plugin is tearing down its scoped effects in LIFO order.
|
||||
FiberUnloading FiberState = "UNLOADING"
|
||||
|
||||
// FiberDisposed indicates the plugin has been completely unmounted and its context disposed.
|
||||
FiberDisposed FiberState = "DISPOSED"
|
||||
|
||||
// FiberSkipped indicates the plugin never activated because its configuration gate
|
||||
// evaluated to false, so an alternative provider took over.
|
||||
FiberSkipped FiberState = "SKIPPED"
|
||||
)
|
||||
|
||||
// Fiber wraps a Plugin instance with a dedicated scoped Context and manages its
|
||||
// reactive lifecycle state machine according to Cordis spatiotemporal composability principles.
|
||||
type Fiber struct {
|
||||
mu sync.RWMutex
|
||||
plugin Plugin
|
||||
state FiberState
|
||||
ctx *Context
|
||||
deps []reflect.Type
|
||||
err error
|
||||
}
|
||||
|
||||
// NewFiber creates a new Fiber for the specified plugin with a child scoped context.
|
||||
func NewFiber(rootCtx *Context, plugin Plugin) *Fiber {
|
||||
var deps []reflect.Type
|
||||
if depPlugin, ok := plugin.(DependentPlugin); ok {
|
||||
deps = depPlugin.Inject()
|
||||
}
|
||||
|
||||
return &Fiber{
|
||||
plugin: plugin,
|
||||
state: FiberPending,
|
||||
ctx: rootCtx.Fork(),
|
||||
deps: deps,
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin returns the underlying Plugin instance.
|
||||
func (f *Fiber) Plugin() Plugin {
|
||||
return f.plugin
|
||||
}
|
||||
|
||||
// Name returns the unique identifier of the plugin.
|
||||
func (f *Fiber) Name() string {
|
||||
if f.plugin == nil {
|
||||
return ""
|
||||
}
|
||||
return f.plugin.Name()
|
||||
}
|
||||
|
||||
// State returns the current lifecycle state of this Fiber.
|
||||
func (f *Fiber) State() FiberState {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
return f.state
|
||||
}
|
||||
|
||||
// Context returns the dedicated scoped Context for this Fiber.
|
||||
func (f *Fiber) Context() *Context {
|
||||
return f.ctx
|
||||
}
|
||||
|
||||
// Dependencies returns the list of required service reflect.Types.
|
||||
func (f *Fiber) Dependencies() []reflect.Type {
|
||||
res := make([]reflect.Type, len(f.deps))
|
||||
copy(res, f.deps)
|
||||
return res
|
||||
}
|
||||
|
||||
// Error returns the latest mounting or unmounting error, if any.
|
||||
func (f *Fiber) Error() error {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
return f.err
|
||||
}
|
||||
|
||||
// DependenciesSatisfied checks if all declared dependencies are present in the target Context container.
|
||||
func (f *Fiber) DependenciesSatisfied(ctx *Context) bool {
|
||||
if len(f.deps) == 0 {
|
||||
return true
|
||||
}
|
||||
container := ctx.Container()
|
||||
for _, dep := range f.deps {
|
||||
if _, err := container.resolve(dep); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Load executes the plugin mounting lifecycle: PENDING -> LOADING -> ACTIVE.
|
||||
func (f *Fiber) Load() error {
|
||||
f.mu.Lock()
|
||||
if f.state != FiberPending {
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
f.state = FiberLoading
|
||||
f.err = nil
|
||||
f.mu.Unlock()
|
||||
|
||||
if err := f.plugin.Apply(f.ctx); err != nil {
|
||||
f.mu.Lock()
|
||||
f.err = fmt.Errorf("fiber %q: apply failed: %w", f.Name(), err)
|
||||
f.state = FiberPending
|
||||
_ = f.ctx.Dispose()
|
||||
f.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.state = FiberActive
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip transitions a pending plugin to FiberSkipped and releases its scoped Context.
|
||||
// Active plugins are left untouched, which makes the call safe to replay on every
|
||||
// reconciliation pass, including for plugins mounted after the first gate evaluation.
|
||||
func (f *Fiber) Skip() error {
|
||||
f.mu.Lock()
|
||||
if f.state != FiberPending {
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
f.state = FiberSkipped
|
||||
f.mu.Unlock()
|
||||
|
||||
return f.ctx.Dispose()
|
||||
}
|
||||
|
||||
// Skipped reports whether the plugin was excluded by its configuration gate.
|
||||
func (f *Fiber) Skipped() bool {
|
||||
return f.State() == FiberSkipped
|
||||
}
|
||||
|
||||
// Unload tears down the plugin: ACTIVE -> UNLOADING -> DISPOSED.
|
||||
func (f *Fiber) Unload() error {
|
||||
f.mu.Lock()
|
||||
if f.state != FiberActive && f.state != FiberLoading {
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
f.state = FiberUnloading
|
||||
f.mu.Unlock()
|
||||
|
||||
err := f.ctx.Dispose()
|
||||
|
||||
f.mu.Lock()
|
||||
f.state = FiberDisposed
|
||||
if err != nil {
|
||||
f.err = err
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/extpoints"
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type MockServiceA interface {
|
||||
DoA() string
|
||||
}
|
||||
|
||||
type mockServiceAImpl struct{}
|
||||
|
||||
func (m *mockServiceAImpl) DoA() string { return "doneA" }
|
||||
|
||||
type MockServiceB interface {
|
||||
DoB() string
|
||||
}
|
||||
|
||||
type mockServiceBImpl struct{}
|
||||
|
||||
func (m *mockServiceBImpl) DoB() string { return "doneB" }
|
||||
|
||||
type mockProviderPlugin struct {
|
||||
name string
|
||||
applied bool
|
||||
}
|
||||
|
||||
func (p *mockProviderPlugin) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *mockProviderPlugin) Apply(ctx *core.Context) error {
|
||||
p.applied = true
|
||||
core.Provide[MockServiceA](ctx, &mockServiceAImpl{})
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockConsumerPlugin struct {
|
||||
name string
|
||||
applied bool
|
||||
gotSvc MockServiceA
|
||||
}
|
||||
|
||||
func (p *mockConsumerPlugin) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *mockConsumerPlugin) Inject() []reflect.Type {
|
||||
return []reflect.Type{
|
||||
reflect.TypeFor[MockServiceA](),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *mockConsumerPlugin) Apply(ctx *core.Context) error {
|
||||
p.applied = true
|
||||
svc, err := core.Inject[MockServiceA](ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.gotSvc = svc
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFiber_ConfluenceAndReactiveActivation(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
|
||||
// Register Consumer BEFORE Provider to test confluence & out-of-order dependency resolution
|
||||
consumer := &mockConsumerPlugin{name: "consumer-plugin"}
|
||||
provider := &mockProviderPlugin{name: "provider-plugin"}
|
||||
|
||||
app.Use(consumer, provider)
|
||||
|
||||
err := app.Start(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, provider.applied, "provider should be applied")
|
||||
assert.True(t, consumer.applied, "consumer should be reactively applied once dependency was provided")
|
||||
assert.NotNil(t, consumer.gotSvc)
|
||||
assert.Equal(t, "doneA", consumer.gotSvc.DoA())
|
||||
|
||||
fibers := app.Fibers()
|
||||
require.Equal(t, 2, len(fibers))
|
||||
for _, f := range fibers {
|
||||
assert.Equal(t, core.FiberActive, f.State())
|
||||
}
|
||||
|
||||
err = app.Stop()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestFiber_UnsatisfiedDependencyReturnsError(t *testing.T) {
|
||||
app := core.NewApp()
|
||||
|
||||
// Register Consumer whose dependency is never provided
|
||||
consumer := &mockConsumerPlugin{name: "consumer-plugin"}
|
||||
app.Use(consumer)
|
||||
|
||||
err := app.Start(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsatisfied dependencies")
|
||||
}
|
||||
|
||||
// gateConfig is the configuration section consumed by gatedPlugin below.
|
||||
type gateConfig struct {
|
||||
Enabled bool `config:"enabled" env:"GATE_ENABLED"`
|
||||
}
|
||||
|
||||
// gatedPlugin implements core.ConfigGatedPlugin: two instances with opposite
|
||||
// expectations model the mutually exclusive cache / cache_memory driver pair.
|
||||
type gatedPlugin struct {
|
||||
name string
|
||||
enabled bool
|
||||
applied bool
|
||||
}
|
||||
|
||||
func (g *gatedPlugin) Name() string { return g.name }
|
||||
|
||||
func (g *gatedPlugin) Apply(_ *core.Context) error {
|
||||
g.applied = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gatedPlugin) DeclareConfig() []extpoints.ConfigBinding {
|
||||
return []extpoints.ConfigBinding{{Prefix: "gate", Target: &gateConfig{}}}
|
||||
}
|
||||
|
||||
func (g *gatedPlugin) ConfigEnabled(view extpoints.ConfigView) bool {
|
||||
return view.Bool("gate.enabled", false) == g.enabled
|
||||
}
|
||||
|
||||
func TestFiberSkipMovesToSkippedStateAndDisposesScope(t *testing.T) {
|
||||
root := core.NewContext(nil)
|
||||
plugin := &gatedPlugin{name: "cache", enabled: true}
|
||||
f := core.NewFiber(root, plugin)
|
||||
require.Equal(t, core.FiberPending, f.State())
|
||||
|
||||
require.NoError(t, f.Skip())
|
||||
|
||||
assert.Equal(t, core.FiberSkipped, f.State())
|
||||
assert.True(t, f.Skipped())
|
||||
assert.False(t, plugin.applied, "a skipped plugin must never reach Apply")
|
||||
assert.NoError(t, f.Unload(), "unloading a skipped fiber is a no-op")
|
||||
}
|
||||
|
||||
func TestFiberSkipIsInertForActiveFibers(t *testing.T) {
|
||||
root := core.NewContext(nil)
|
||||
f := core.NewFiber(root, &gatedPlugin{name: "cache", enabled: true})
|
||||
require.NoError(t, f.Load())
|
||||
|
||||
require.NoError(t, f.Skip())
|
||||
|
||||
assert.Equal(t, core.FiberActive, f.State(), "Skip only applies to pending fibers")
|
||||
assert.False(t, f.Skipped())
|
||||
}
|
||||
@@ -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,227 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"Wavelet/core/extpoints"
|
||||
)
|
||||
|
||||
// scopedRouterExtension wraps a RouterExtension to automatically register
|
||||
// teardown disposers on the associated Context when routes are declared.
|
||||
type scopedRouterExtension struct {
|
||||
underlying extpoints.RouterExtension
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func newScopedRouterExtension(ctx *Context, underlying extpoints.RouterExtension) extpoints.RouterExtension {
|
||||
return &scopedRouterExtension{
|
||||
underlying: underlying,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) Use(middlewares ...any) {
|
||||
s.underlying.Use(middlewares...)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) Group(prefix string, middlewares ...any) extpoints.RouterExtension {
|
||||
subGroup := s.underlying.Group(prefix, middlewares...)
|
||||
return newScopedRouterExtension(s.ctx, subGroup)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) Handle(method, path string, handlers ...any) extpoints.RouteDefinition {
|
||||
rd := s.underlying.Handle(method, path, handlers...)
|
||||
routeID := rd.ID
|
||||
s.ctx.OnDispose(func() error {
|
||||
s.underlying.UnregisterByID(routeID)
|
||||
return nil
|
||||
})
|
||||
return rd
|
||||
}
|
||||
|
||||
// HandleRaw registers a trailing-slash-preserving route and tears it down with the scope.
|
||||
func (s *scopedRouterExtension) HandleRaw(method, path string, handlers ...any) extpoints.RouteDefinition {
|
||||
rd := s.underlying.HandleRaw(method, path, handlers...)
|
||||
routeID := rd.ID
|
||||
s.ctx.OnDispose(func() error {
|
||||
s.underlying.UnregisterByID(routeID)
|
||||
return nil
|
||||
})
|
||||
return rd
|
||||
}
|
||||
|
||||
// BasePath delegates to the wrapped group prefix.
|
||||
func (s *scopedRouterExtension) BasePath() string { return s.underlying.BasePath() }
|
||||
|
||||
func (s *scopedRouterExtension) GET(path string, handlers ...any) extpoints.RouteDefinition {
|
||||
return s.Handle("GET", path, handlers...)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) POST(path string, handlers ...any) extpoints.RouteDefinition {
|
||||
return s.Handle("POST", path, handlers...)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) PUT(path string, handlers ...any) extpoints.RouteDefinition {
|
||||
return s.Handle("PUT", path, handlers...)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) DELETE(path string, handlers ...any) extpoints.RouteDefinition {
|
||||
return s.Handle("DELETE", path, handlers...)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) PATCH(path string, handlers ...any) extpoints.RouteDefinition {
|
||||
return s.Handle("PATCH", path, handlers...)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) HEAD(path string, handlers ...any) extpoints.RouteDefinition {
|
||||
return s.Handle("HEAD", path, handlers...)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) OPTIONS(path string, handlers ...any) extpoints.RouteDefinition {
|
||||
return s.Handle("OPTIONS", path, handlers...)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) Any(path string, handlers ...any) []extpoints.RouteDefinition {
|
||||
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
|
||||
defs := make([]extpoints.RouteDefinition, 0, len(methods))
|
||||
for _, m := range methods {
|
||||
defs = append(defs, s.Handle(m, path, handlers...))
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) Routes() []extpoints.RouteDefinition {
|
||||
return s.underlying.Routes()
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) Middlewares() []any {
|
||||
return s.underlying.Middlewares()
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) Unregister(method, path string) bool {
|
||||
return s.underlying.Unregister(method, path)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) UnregisterByID(id uint64) bool {
|
||||
return s.underlying.UnregisterByID(id)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) RegisterWhitelist(patterns ...string) {
|
||||
s.underlying.RegisterWhitelist(patterns...)
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) Whitelist() []string {
|
||||
return s.underlying.Whitelist()
|
||||
}
|
||||
|
||||
func (s *scopedRouterExtension) IsWhitelisted(path string) bool {
|
||||
return s.underlying.IsWhitelisted(path)
|
||||
}
|
||||
|
||||
// scopedTaskExtension wraps a TaskExtension to automatically register
|
||||
// teardown disposers on the associated Context when task handlers are declared.
|
||||
type scopedTaskExtension struct {
|
||||
underlying extpoints.TaskExtension
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func newScopedTaskExtension(ctx *Context, underlying extpoints.TaskExtension) extpoints.TaskExtension {
|
||||
return &scopedTaskExtension{
|
||||
underlying: underlying,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scopedTaskExtension) Register(pattern string, handler any, opts ...extpoints.TaskOption) {
|
||||
s.underlying.Register(pattern, handler, opts...)
|
||||
s.ctx.OnDispose(func() error {
|
||||
s.underlying.Unregister(pattern)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *scopedTaskExtension) Tasks() []extpoints.TaskDefinition {
|
||||
return s.underlying.Tasks()
|
||||
}
|
||||
|
||||
func (s *scopedTaskExtension) Get(pattern string) (extpoints.TaskDefinition, bool) {
|
||||
return s.underlying.Get(pattern)
|
||||
}
|
||||
|
||||
func (s *scopedTaskExtension) Unregister(pattern string) bool {
|
||||
return s.underlying.Unregister(pattern)
|
||||
}
|
||||
|
||||
// scopedScheduleExtension wraps a ScheduleExtension to automatically register
|
||||
// teardown disposers on the associated Context when cron/scheduled tasks are declared.
|
||||
type scopedScheduleExtension struct {
|
||||
underlying extpoints.ScheduleExtension
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func newScopedScheduleExtension(ctx *Context, underlying extpoints.ScheduleExtension) extpoints.ScheduleExtension {
|
||||
return &scopedScheduleExtension{
|
||||
underlying: underlying,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scopedScheduleExtension) Register(spec, taskType string, payload any, opts ...extpoints.ScheduleOption) {
|
||||
s.underlying.Register(spec, taskType, payload, opts...)
|
||||
s.ctx.OnDispose(func() error {
|
||||
s.underlying.Unregister(taskType)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *scopedScheduleExtension) RegisterCron(spec, taskType string, payload any, opts ...extpoints.ScheduleOption) {
|
||||
s.Register(spec, taskType, payload, opts...)
|
||||
}
|
||||
|
||||
func (s *scopedScheduleExtension) Schedules() []extpoints.ScheduleDefinition {
|
||||
return s.underlying.Schedules()
|
||||
}
|
||||
|
||||
func (s *scopedScheduleExtension) Get(taskType string) (extpoints.ScheduleDefinition, bool) {
|
||||
return s.underlying.Get(taskType)
|
||||
}
|
||||
|
||||
func (s *scopedScheduleExtension) Unregister(taskType string) bool {
|
||||
return s.underlying.Unregister(taskType)
|
||||
}
|
||||
|
||||
// scopedSettingExtension wraps a SettingExtension to automatically register
|
||||
// teardown disposers on the associated Context when settings schemas are declared.
|
||||
type scopedSettingExtension struct {
|
||||
underlying extpoints.SettingExtension
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func newScopedSettingExtension(ctx *Context, underlying extpoints.SettingExtension) extpoints.SettingExtension {
|
||||
return &scopedSettingExtension{
|
||||
underlying: underlying,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scopedSettingExtension) Register(schema extpoints.SettingSchema) {
|
||||
s.underlying.Register(schema)
|
||||
key := schema.Key
|
||||
s.ctx.OnDispose(func() error {
|
||||
s.underlying.Unregister(key)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *scopedSettingExtension) Schemas() []extpoints.SettingSchema {
|
||||
return s.underlying.Schemas()
|
||||
}
|
||||
|
||||
func (s *scopedSettingExtension) Get(key string) (extpoints.SettingSchema, bool) {
|
||||
return s.underlying.Get(key)
|
||||
}
|
||||
|
||||
func (s *scopedSettingExtension) Unregister(key string) bool {
|
||||
return s.underlying.Unregister(key)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"Wavelet/core/extpoints"
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// DependentPlugin is an optional extension interface for plugins that declare required service dependencies.
|
||||
type DependentPlugin interface {
|
||||
Plugin
|
||||
Inject() []reflect.Type
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// ConfigExtension re-exports extpoints.ConfigExtension.
|
||||
type ConfigExtension = extpoints.ConfigExtension
|
||||
|
||||
// ConfigSource re-exports extpoints.ConfigSource.
|
||||
type ConfigSource = extpoints.ConfigSource
|
||||
|
||||
// ConfigBinding re-exports extpoints.ConfigBinding.
|
||||
type ConfigBinding = extpoints.ConfigBinding
|
||||
|
||||
// ConfigView re-exports extpoints.ConfigView.
|
||||
type ConfigView = extpoints.ConfigView
|
||||
|
||||
// ConfigEntry re-exports extpoints.ConfigEntry.
|
||||
type ConfigEntry = extpoints.ConfigEntry
|
||||
|
||||
// ConfigGatedPlugin is an optional interface for plugins whose activation depends on
|
||||
// configuration. The kernel evaluates the gate during reconciliation, before the
|
||||
// plugin's Apply runs, so every key read by ConfigEnabled must be published through
|
||||
// DeclareConfig.
|
||||
type ConfigGatedPlugin interface {
|
||||
Plugin
|
||||
|
||||
// DeclareConfig publishes the configuration bindings consumed by ConfigEnabled.
|
||||
DeclareConfig() []extpoints.ConfigBinding
|
||||
|
||||
// ConfigEnabled reports whether this plugin should activate for the resolved values.
|
||||
ConfigEnabled(view extpoints.ConfigView) bool
|
||||
}
|
||||
Reference in New Issue
Block a user