mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 15:06:37 +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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user