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:
ryan
2026-08-29 19:28:39 +08:00
parent 9f79fb9969
commit dbaa3bf140
1327 changed files with 91634 additions and 4157 deletions
+284
View File
@@ -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
}
+283
View File
@@ -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]
}
+245
View File
@@ -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")
}
+243
View File
@@ -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 ""
}
+383
View File
@@ -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)
}
+94
View File
@@ -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
}
+22
View File
@@ -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
}
+558
View File
@@ -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 }
+52
View File
@@ -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)
}
}
+111
View File
@@ -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
}
+88
View File
@@ -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
}
+285
View File
@@ -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
}
+157
View File
@@ -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")
}
}