Resolve "Deprecate Providers package in favor of Worker"
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
// Package domain defines external message identifiers.
|
||||
package domain
|
||||
|
||||
import (
|
||||
@@ -9,56 +8,96 @@ import (
|
||||
|
||||
var ErrBadIdentifier = errors.New("identifier: invalid format")
|
||||
|
||||
// Identifier is a lightweight wrapper around the canonical key.
|
||||
// Identifier is an immutable canonical key.
|
||||
// Canonical form: "namespace::tag1[] . tag2[k=v;foo=bar] . tag3[]"
|
||||
type Identifier struct{ key string }
|
||||
|
||||
// NewIdentifier builds a canonical key: "namespace::l1.l2[param=v;...] .l3".
|
||||
// Labels and params are sorted for determinism.
|
||||
func NewIdentifier(namespace string, labels map[string]map[string]string) Identifier {
|
||||
// NewIdentifier builds a canonical key with strict validation.
|
||||
// Tags and param keys are sorted. Tags with no params emit "name[]".
|
||||
// Rejects on: empty namespace, bad tag names, bad keys/values.
|
||||
func NewIdentifier(namespace string, tags map[string]map[string]string) (Identifier, error) {
|
||||
ns := strings.TrimSpace(namespace)
|
||||
if !validNamespace(ns) {
|
||||
return Identifier{}, ErrBadIdentifier
|
||||
}
|
||||
|
||||
// Validate and copy to protect immutability and reject dup keys early.
|
||||
clean := make(map[string]map[string]string, len(tags))
|
||||
for name, params := range tags {
|
||||
n := strings.TrimSpace(name)
|
||||
if !validIDTagName(n) {
|
||||
return Identifier{}, ErrBadIdentifier
|
||||
}
|
||||
if _, exists := clean[n]; exists {
|
||||
// impossible via map input, but keep the intent explicit
|
||||
return Identifier{}, ErrBadIdentifier
|
||||
}
|
||||
if len(params) == 0 {
|
||||
clean[n] = map[string]string{}
|
||||
continue
|
||||
}
|
||||
dst := make(map[string]string, len(params))
|
||||
for k, v := range params {
|
||||
kk := strings.TrimSpace(k)
|
||||
vv := strings.TrimSpace(v)
|
||||
if !validParamKey(kk) || !validIDParamValue(vv) {
|
||||
return Identifier{}, ErrBadIdentifier
|
||||
}
|
||||
if _, dup := dst[kk]; dup {
|
||||
return Identifier{}, ErrBadIdentifier
|
||||
}
|
||||
dst[kk] = vv
|
||||
}
|
||||
clean[n] = dst
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
// rough prealloc: ns + "::" + avg label + some params
|
||||
b.Grow(len(namespace) + 2 + 10*len(labels) + 20)
|
||||
// Rough capacity hint.
|
||||
b.Grow(len(ns) + 2 + 16*len(clean) + 32)
|
||||
|
||||
// namespace
|
||||
b.WriteString(namespace)
|
||||
b.WriteString(ns)
|
||||
b.WriteString("::")
|
||||
|
||||
// sort label names for stable output
|
||||
labelNames := make([]string, 0, len(labels))
|
||||
for name := range labels {
|
||||
labelNames = append(labelNames, name)
|
||||
// stable tag order
|
||||
names := make([]string, 0, len(clean))
|
||||
for n := range clean {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(labelNames)
|
||||
sort.Strings(names)
|
||||
|
||||
for i, name := range labelNames {
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteByte('.')
|
||||
}
|
||||
b.WriteString(name)
|
||||
|
||||
// params (sorted)
|
||||
params := labels[name]
|
||||
if len(params) > 0 {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b.WriteByte('[')
|
||||
for j, k := range keys {
|
||||
if j > 0 {
|
||||
b.WriteByte(';')
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(params[k])
|
||||
}
|
||||
b.WriteByte(']')
|
||||
params := clean[name]
|
||||
if len(params) == 0 {
|
||||
b.WriteString("[]")
|
||||
continue
|
||||
}
|
||||
|
||||
// stable param order
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
b.WriteByte('[')
|
||||
for j, k := range keys {
|
||||
if j > 0 {
|
||||
b.WriteByte(';')
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(params[k])
|
||||
}
|
||||
b.WriteByte(']')
|
||||
}
|
||||
|
||||
return Identifier{key: b.String()}
|
||||
return Identifier{key: b.String()}, nil
|
||||
}
|
||||
|
||||
// NewIdentifierFromRaw wraps a raw key without validation.
|
||||
@@ -67,65 +106,156 @@ func NewIdentifierFromRaw(raw string) Identifier { return Identifier{key: raw} }
|
||||
// Key returns the canonical key string.
|
||||
func (id Identifier) Key() string { return id.key }
|
||||
|
||||
// Parse returns namespace and labels from Key.
|
||||
// Format: "namespace::label1.label2[param=a;foo=bar].label3"
|
||||
// Parse returns namespace and tags from Key.
|
||||
// Accepts "tag" (bare) as "tag[]". Emits "name[]"/"[k=v;...]". First token wins on duplicates.
|
||||
func (id Identifier) Parse() (string, map[string]map[string]string, error) {
|
||||
k := id.key
|
||||
|
||||
// namespace
|
||||
i := strings.Index(k, "::")
|
||||
if i <= 0 {
|
||||
return "", nil, ErrBadIdentifier
|
||||
}
|
||||
ns := k[:i]
|
||||
if ns == "" {
|
||||
ns := strings.TrimSpace(k[:i])
|
||||
if !validNamespace(ns) {
|
||||
return "", nil, ErrBadIdentifier
|
||||
}
|
||||
raw := k[i+2:]
|
||||
|
||||
lbls := make(map[string]map[string]string, 8)
|
||||
tags := make(map[string]map[string]string, 8)
|
||||
if raw == "" {
|
||||
return ns, lbls, nil
|
||||
return ns, tags, nil
|
||||
}
|
||||
|
||||
for tok := range strings.SplitSeq(raw, ".") {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
name, params, err := parseLabel(tok)
|
||||
if err != nil || name == "" {
|
||||
return "", nil, ErrBadIdentifier
|
||||
}
|
||||
lbls[name] = params
|
||||
}
|
||||
return ns, lbls, nil
|
||||
}
|
||||
|
||||
// parseLabel parses "name" or "name[k=v;...]" into (name, params).
|
||||
func parseLabel(tok string) (string, map[string]string, error) {
|
||||
lb := strings.IndexByte(tok, '[')
|
||||
if lb == -1 {
|
||||
return tok, map[string]string{}, nil
|
||||
}
|
||||
rb := strings.LastIndexByte(tok, ']')
|
||||
if rb == -1 || rb < lb {
|
||||
return "", nil, ErrBadIdentifier
|
||||
}
|
||||
|
||||
name := tok[:lb]
|
||||
paramStr := tok[lb+1 : rb]
|
||||
params := map[string]string{}
|
||||
if paramStr == "" {
|
||||
return name, params, nil
|
||||
}
|
||||
|
||||
for pair := range strings.SplitSeq(paramStr, ";") {
|
||||
if pair == "" {
|
||||
lb := strings.IndexByte(tok, '[')
|
||||
if lb == -1 {
|
||||
// bare tag => empty params
|
||||
name := strings.TrimSpace(tok)
|
||||
if !validIDTagName(name) {
|
||||
return "", nil, ErrBadIdentifier
|
||||
}
|
||||
if _, exists := tags[name]; !exists {
|
||||
tags[name] = map[string]string{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 || kv[0] == "" {
|
||||
|
||||
rb := strings.LastIndexByte(tok, ']')
|
||||
if rb == -1 || rb < lb || rb != len(tok)-1 {
|
||||
return "", nil, ErrBadIdentifier
|
||||
}
|
||||
params[kv[0]] = kv[1]
|
||||
|
||||
name := strings.TrimSpace(tok[:lb])
|
||||
if !validIDTagName(name) {
|
||||
return "", nil, ErrBadIdentifier
|
||||
}
|
||||
// first tag wins
|
||||
if _, exists := tags[name]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
body := tok[lb+1 : rb]
|
||||
// forbid outer whitespace like "[ x=1 ]"
|
||||
if body != strings.TrimSpace(body) {
|
||||
return "", nil, ErrBadIdentifier
|
||||
}
|
||||
if body == "" {
|
||||
tags[name] = map[string]string{}
|
||||
continue
|
||||
}
|
||||
|
||||
// parse "k=v;foo=bar"
|
||||
params := make(map[string]string, 4)
|
||||
for pair := range strings.SplitSeq(body, ";") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
return "", nil, ErrBadIdentifier
|
||||
}
|
||||
key := strings.TrimSpace(kv[0])
|
||||
val := strings.TrimSpace(kv[1])
|
||||
if !validParamKey(key) || !validIDParamValue(val) || val == "" {
|
||||
return "", nil, ErrBadIdentifier
|
||||
}
|
||||
// first key wins
|
||||
if _, dup := params[key]; !dup {
|
||||
params[key] = val
|
||||
}
|
||||
}
|
||||
tags[name] = params
|
||||
}
|
||||
return name, params, nil
|
||||
|
||||
return ns, tags, nil
|
||||
}
|
||||
|
||||
// --- validation helpers ---
|
||||
|
||||
func validNamespace(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '[', ']', ':':
|
||||
return false
|
||||
}
|
||||
if isSpace(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validIDTagName(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '[', ']', '.', ':': // added ':'
|
||||
return false
|
||||
}
|
||||
if isSpace(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validParamKey(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '[', ']', ';', '=':
|
||||
return false
|
||||
}
|
||||
if isSpace(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validIDParamValue(s string) bool {
|
||||
// allow spaces; forbid only bracket, pair, and kv delimiters
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '[', ']', ';', '=':
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isSpace(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || r == '\r' }
|
||||
|
||||
141
services/data_service/internal/domain/identifier_test.go
Normal file
141
services/data_service/internal/domain/identifier_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewIdentifier_CanonicalAndValidation(t *testing.T) {
|
||||
t.Run("canonical ordering and formatting", func(t *testing.T) {
|
||||
id, err := NewIdentifier("ns", map[string]map[string]string{
|
||||
"b": {"y": "2", "x": "1"},
|
||||
"a": {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
got := id.Key()
|
||||
want := "ns::a[].b[x=1;y=2]"
|
||||
if got != want {
|
||||
t.Fatalf("key mismatch\ngot: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trim whitespace and validate", func(t *testing.T) {
|
||||
id, err := NewIdentifier(" ns ", map[string]map[string]string{
|
||||
" tag ": {" k ": " v "},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if id.Key() != "ns::tag[k=v]" {
|
||||
t.Fatalf("unexpected canonical: %q", id.Key())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject bad namespace", func(t *testing.T) {
|
||||
cases := []string{"", "a:b", "a[b]"}
|
||||
for _, ns := range cases {
|
||||
if _, err := NewIdentifier(ns, nil); err == nil {
|
||||
t.Fatalf("expected error for ns=%q", ns)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject bad tag names", func(t *testing.T) {
|
||||
for _, name := range []string{"", "bad.", "bad[", "bad]", "a:b"} {
|
||||
_, err := NewIdentifier("ns", map[string]map[string]string{
|
||||
name: {},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for tag name %q", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject bad param keys and values", func(t *testing.T) {
|
||||
badKeys := []string{"", "k;", "k[", "k]", "k="}
|
||||
for _, k := range badKeys {
|
||||
if _, err := NewIdentifier("ns", map[string]map[string]string{
|
||||
"t": {k: "ok"},
|
||||
}); err == nil {
|
||||
t.Fatalf("expected error for bad key %q", k)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"bad;", "bad[", "bad]", "a=b"} {
|
||||
if _, err := NewIdentifier("ns", map[string]map[string]string{
|
||||
"t": {"k": v},
|
||||
}); err == nil {
|
||||
t.Fatalf("expected error for bad value %q", v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIdentifier_Parse_RoundTripAndTolerance(t *testing.T) {
|
||||
t.Run("round trip from constructor", func(t *testing.T) {
|
||||
id, err := NewIdentifier("ns", map[string]map[string]string{
|
||||
"a": {},
|
||||
"b": {"x": "1", "y": "2"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ns, tags, err := id.Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ns != "ns" {
|
||||
t.Fatalf("ns: got %q", ns)
|
||||
}
|
||||
want := map[string]map[string]string{"a": {}, "b": {"x": "1", "y": "2"}}
|
||||
if !reflect.DeepEqual(tags, want) {
|
||||
t.Fatalf("tags mismatch\ngot: %#v\nwant: %#v", tags, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parse bare tag as empty params", func(t *testing.T) {
|
||||
id := NewIdentifierFromRaw("ns::a.b[]")
|
||||
_, tags, err := id.Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tags["a"]) != 0 || len(tags["b"]) != 0 {
|
||||
t.Fatalf("expected empty params, got %#v", tags)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("first token wins on duplicate tags and params", func(t *testing.T) {
|
||||
id := NewIdentifierFromRaw("ns::t[x=1;y=2].t[x=9].u[k=1;k=2]")
|
||||
_, tags, err := id.Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tags["t"]["x"] != "1" || tags["t"]["y"] != "2" {
|
||||
t.Fatalf("first tag should win, got %#v", tags["t"])
|
||||
}
|
||||
if tags["u"]["k"] != "1" {
|
||||
t.Fatalf("first param key should win, got %#v", tags["u"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject malformed", func(t *testing.T) {
|
||||
bads := []string{
|
||||
"", "no_ns", "ns:onecolon", "::missingns::tag[]", "ns::tag[", "ns::tag]", "ns::[]",
|
||||
"ns::tag[]junk", "ns::tag[x=1;y]", "ns::tag[=1]", "ns::tag[ x=1 ]", // spaces inside keys are rejected
|
||||
}
|
||||
for _, s := range bads {
|
||||
_, _, err := NewIdentifierFromRaw(s).Parse()
|
||||
if err == nil {
|
||||
t.Fatalf("expected parse error for %q", s)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIdentifier_NewThenParse_ForbidsColonInTagName(t *testing.T) {
|
||||
_, err := NewIdentifier("ns", map[string]map[string]string{"a:b": {}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error due to ':' in tag name")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package domain contains all key domain types
|
||||
package domain
|
||||
|
||||
type Message struct {
|
||||
|
||||
@@ -1,45 +1,114 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Pattern struct {
|
||||
Namespace string
|
||||
Labels map[string]map[string]string
|
||||
Exact bool
|
||||
var ErrBadPattern = errors.New("pattern: invalid format")
|
||||
|
||||
// ParamMatchKind selects how a tag's params must match.
|
||||
type ParamMatchKind uint8
|
||||
|
||||
const (
|
||||
MatchAny ParamMatchKind = iota // "tag" or "tag[*]"
|
||||
MatchNone // "tag[]"
|
||||
MatchExact // "tag[k=v;...]"
|
||||
)
|
||||
|
||||
// TagSpec is the per-tag constraint.
|
||||
type TagSpec struct {
|
||||
Kind ParamMatchKind
|
||||
Params map[string]string // only for MatchExact; keys sorted on emit
|
||||
}
|
||||
|
||||
// Canonical returns a canonical string representation of the Pattern struct
|
||||
// TODO: Ensure labels and namespaces are set to lowercase
|
||||
func (p *Pattern) Canonical() string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(p.Namespace) + 10*len(p.Labels) + 20) // preallocate a rough size estimate
|
||||
// Pattern is an immutable canonical key.
|
||||
// Canonical form (tags unordered in input, sorted on emit):
|
||||
//
|
||||
// namespace::tag1[]:tag2[*]:tag3[k=v;foo=bar].* // superset
|
||||
// namespace::tag1[]:tag2[*]:tag3[k=v;foo=bar] // exact set
|
||||
type Pattern struct{ key string }
|
||||
|
||||
b.WriteString(p.Namespace)
|
||||
// NewPattern builds the canonical key from structured input with strict validation.
|
||||
// If a tag name equals "*" it sets superset and omits it from canonical tags.
|
||||
func NewPattern(namespace string, tags map[string]TagSpec, superset bool) (Pattern, error) {
|
||||
ns := strings.TrimSpace(namespace)
|
||||
if !validNamespace(ns) {
|
||||
return Pattern{}, ErrBadPattern
|
||||
}
|
||||
|
||||
// Validate tags and normalize.
|
||||
clean := make(map[string]TagSpec, len(tags))
|
||||
for name, spec := range tags {
|
||||
n := strings.TrimSpace(name)
|
||||
if n == "*" {
|
||||
superset = true
|
||||
continue
|
||||
}
|
||||
if !validPatternTagName(n) {
|
||||
return Pattern{}, ErrBadPattern
|
||||
}
|
||||
switch spec.Kind {
|
||||
case MatchAny:
|
||||
clean[n] = TagSpec{Kind: MatchAny}
|
||||
case MatchNone:
|
||||
clean[n] = TagSpec{Kind: MatchNone}
|
||||
case MatchExact:
|
||||
if len(spec.Params) == 0 {
|
||||
// Treat empty exact as none.
|
||||
clean[n] = TagSpec{Kind: MatchNone}
|
||||
continue
|
||||
}
|
||||
dst := make(map[string]string, len(spec.Params))
|
||||
for k, v := range spec.Params {
|
||||
kk := strings.TrimSpace(k)
|
||||
vv := strings.TrimSpace(v)
|
||||
if !validParamKey(kk) || !validPatternParamValue(vv) {
|
||||
return Pattern{}, ErrBadPattern
|
||||
}
|
||||
if _, dup := dst[kk]; dup {
|
||||
return Pattern{}, ErrBadPattern
|
||||
}
|
||||
dst[kk] = vv
|
||||
}
|
||||
clean[n] = TagSpec{Kind: MatchExact, Params: dst}
|
||||
default:
|
||||
// Reject unknown kinds rather than silently defaulting.
|
||||
return Pattern{}, ErrBadPattern
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(ns) + 2 + 16*len(clean) + 32 + 2)
|
||||
|
||||
b.WriteString(ns)
|
||||
b.WriteString("::")
|
||||
|
||||
labelNames := make([]string, 0, len(p.Labels))
|
||||
for name := range p.Labels {
|
||||
labelNames = append(labelNames, name)
|
||||
names := make([]string, 0, len(clean))
|
||||
for n := range clean {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(labelNames) // sort the labels for determinism
|
||||
sort.Strings(names)
|
||||
|
||||
for i, name := range labelNames {
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteByte('|')
|
||||
b.WriteByte(':')
|
||||
}
|
||||
b.WriteString(name)
|
||||
|
||||
params := p.Labels[name]
|
||||
if len(params) > 0 {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
spec := clean[name]
|
||||
switch spec.Kind {
|
||||
case MatchAny:
|
||||
b.WriteString("[*]")
|
||||
case MatchNone:
|
||||
b.WriteString("[]")
|
||||
case MatchExact:
|
||||
keys := make([]string, 0, len(spec.Params))
|
||||
for k := range spec.Params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys) // sort params for determinism
|
||||
|
||||
sort.Strings(keys)
|
||||
b.WriteByte('[')
|
||||
for j, k := range keys {
|
||||
if j > 0 {
|
||||
@@ -47,48 +116,232 @@ func (p *Pattern) Canonical() string {
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(params[k])
|
||||
b.WriteString(spec.Params[k])
|
||||
}
|
||||
b.WriteByte(']')
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("::")
|
||||
if p.Exact {
|
||||
b.WriteString("t")
|
||||
} else {
|
||||
b.WriteString("f")
|
||||
if superset {
|
||||
if len(names) > 0 {
|
||||
b.WriteByte('.')
|
||||
}
|
||||
b.WriteByte('*')
|
||||
}
|
||||
|
||||
return b.String()
|
||||
return Pattern{key: b.String()}, nil
|
||||
}
|
||||
|
||||
// Satisfies checks if a domain.Identifier satisfies the pattern.
|
||||
func (p *Pattern) Satisfies(id Identifier) bool {
|
||||
ns, idLabels, err := id.Parse()
|
||||
if err != nil || ns != p.Namespace {
|
||||
// NewPatternFromRaw wraps a raw key without validation.
|
||||
func NewPatternFromRaw(raw string) Pattern { return Pattern{key: raw} }
|
||||
|
||||
// Key returns the canonical key string.
|
||||
func (p Pattern) Key() string { return p.key }
|
||||
|
||||
// Parse returns namespace, tag specs, and superset flag.
|
||||
// Accepts tokens: "tag", "tag[*]", "tag[]", "tag[k=v;...]". Also accepts ".*" suffix or a ":*" token anywhere.
|
||||
// First token wins on duplicate tag names; first key wins on duplicate params.
|
||||
func (p Pattern) Parse() (string, map[string]TagSpec, bool, error) {
|
||||
k := p.key
|
||||
|
||||
// namespace
|
||||
i := strings.Index(k, "::")
|
||||
if i <= 0 {
|
||||
return "", nil, false, ErrBadPattern
|
||||
}
|
||||
ns := strings.TrimSpace(k[:i])
|
||||
if !validNamespace(ns) {
|
||||
return "", nil, false, ErrBadPattern
|
||||
}
|
||||
raw := k[i+2:]
|
||||
|
||||
// suffix superset ".*"
|
||||
superset := false
|
||||
if strings.HasSuffix(raw, ".*") {
|
||||
superset = true
|
||||
raw = raw[:len(raw)-2]
|
||||
}
|
||||
|
||||
specs := make(map[string]TagSpec, 8)
|
||||
if raw == "" {
|
||||
return ns, specs, superset, nil
|
||||
}
|
||||
|
||||
for tok := range strings.SplitSeq(raw, ":") {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
if tok == "*" {
|
||||
superset = true
|
||||
continue
|
||||
}
|
||||
|
||||
lb := strings.IndexByte(tok, '[')
|
||||
if lb == -1 {
|
||||
name := tok
|
||||
if !validPatternTagName(name) {
|
||||
return "", nil, false, ErrBadPattern
|
||||
}
|
||||
if _, exists := specs[name]; !exists {
|
||||
specs[name] = TagSpec{Kind: MatchAny}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
rb := strings.LastIndexByte(tok, ']')
|
||||
if rb == -1 || rb < lb || rb != len(tok)-1 {
|
||||
return "", nil, false, ErrBadPattern
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(tok[:lb])
|
||||
if !validPatternTagName(name) {
|
||||
return "", nil, false, ErrBadPattern
|
||||
}
|
||||
// first tag wins
|
||||
if _, exists := specs[name]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
rawBody := tok[lb+1 : rb]
|
||||
// forbid outer whitespace like "[ x=1 ]"
|
||||
if rawBody != strings.TrimSpace(rawBody) {
|
||||
return "", nil, false, ErrBadPattern
|
||||
}
|
||||
body := strings.TrimSpace(rawBody)
|
||||
|
||||
switch body {
|
||||
case "":
|
||||
specs[name] = TagSpec{Kind: MatchNone}
|
||||
case "*":
|
||||
specs[name] = TagSpec{Kind: MatchAny}
|
||||
default:
|
||||
params := make(map[string]string, 4)
|
||||
for pair := range strings.SplitSeq(body, ";") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
return "", nil, false, ErrBadPattern
|
||||
}
|
||||
key := strings.TrimSpace(kv[0])
|
||||
val := strings.TrimSpace(kv[1])
|
||||
if !validParamKey(key) || !validPatternParamValue(val) || val == "" {
|
||||
return "", nil, false, ErrBadPattern
|
||||
}
|
||||
// first key wins
|
||||
if _, dup := params[key]; !dup {
|
||||
params[key] = val
|
||||
}
|
||||
}
|
||||
specs[name] = TagSpec{Kind: MatchExact, Params: params}
|
||||
}
|
||||
}
|
||||
|
||||
return ns, specs, superset, nil
|
||||
}
|
||||
|
||||
// Equal compares canonical keys.
|
||||
func (p Pattern) Equal(q Pattern) bool { return p.key == q.key }
|
||||
|
||||
// CompiledPattern is a parsed pattern optimized for matching.
|
||||
type CompiledPattern struct {
|
||||
ns string
|
||||
superset bool
|
||||
specs map[string]TagSpec
|
||||
}
|
||||
|
||||
// Compile parses and returns a compiled form.
|
||||
func (p Pattern) Compile() (CompiledPattern, error) {
|
||||
ns, specs, sup, err := p.Parse()
|
||||
if err != nil {
|
||||
return CompiledPattern{}, err
|
||||
}
|
||||
return CompiledPattern{ns: ns, specs: specs, superset: sup}, nil
|
||||
}
|
||||
|
||||
// Parse on CompiledPattern returns the structured contents without error.
|
||||
func (cp CompiledPattern) Parse() (namespace string, tags map[string]TagSpec, superset bool) {
|
||||
return cp.ns, cp.specs, cp.superset
|
||||
}
|
||||
|
||||
// Match parses id and tests it against the pattern.
|
||||
// Returns false on parse error.
|
||||
func (p Pattern) Match(id Identifier) bool {
|
||||
cp, err := p.Compile()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return cp.Match(id)
|
||||
}
|
||||
|
||||
// Match tests id against the compiled pattern.
|
||||
func (cp CompiledPattern) Match(id Identifier) bool {
|
||||
ns, tags, err := id.Parse()
|
||||
if err != nil || ns != cp.ns {
|
||||
return false
|
||||
}
|
||||
|
||||
// Every pattern label must be present in the identifier.
|
||||
for lname, wantParams := range p.Labels {
|
||||
haveParams, ok := idLabels[lname]
|
||||
// All pattern tags must be satisfied.
|
||||
for name, spec := range cp.specs {
|
||||
params, ok := tags[name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// If pattern specifies params, they must be a subset of identifier's params.
|
||||
for k, v := range wantParams {
|
||||
hv, ok := haveParams[k]
|
||||
if !ok || hv != v {
|
||||
switch spec.Kind {
|
||||
case MatchAny:
|
||||
// any or none is fine
|
||||
case MatchNone:
|
||||
if len(params) != 0 {
|
||||
return false
|
||||
}
|
||||
case MatchExact:
|
||||
if len(params) != len(spec.Params) {
|
||||
return false
|
||||
}
|
||||
for k, v := range spec.Params {
|
||||
if params[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// If pattern has no params for this label, it matches any/none params in the identifier.
|
||||
}
|
||||
|
||||
// Exact applies to label names only: no extras allowed.
|
||||
if p.Exact && len(idLabels) != len(p.Labels) {
|
||||
// If exact-set match, forbid extra tags.
|
||||
if !cp.superset && len(tags) != len(cp.specs) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --- validation helpers ---
|
||||
|
||||
func validPatternTagName(s string) bool {
|
||||
if s == "" || s == "*" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '[', ']', ':':
|
||||
return false
|
||||
}
|
||||
if isSpace(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validPatternParamValue(s string) bool {
|
||||
// allow spaces; forbid only bracket, pair, and kv delimiters
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '[', ']', ';', '=':
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
209
services/data_service/internal/domain/pattern_test.go
Normal file
209
services/data_service/internal/domain/pattern_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPattern_Canonical_And_Superset(t *testing.T) {
|
||||
t.Run("canonical ordering and kinds", func(t *testing.T) {
|
||||
p, err := NewPattern("ns", map[string]TagSpec{
|
||||
"b": {Kind: MatchExact, Params: map[string]string{"y": "2", "x": "1"}},
|
||||
"a": {Kind: MatchNone},
|
||||
"c": {Kind: MatchAny},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := p.Key(), "ns::a[]:b[x=1;y=2]:c[*]"; got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("superset via flag", func(t *testing.T) {
|
||||
p, err := NewPattern("ns", map[string]TagSpec{"a": {Kind: MatchNone}}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := p.Key(), "ns::a[].*"; got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("superset via '*' tag anywhere", func(t *testing.T) {
|
||||
p, err := NewPattern("ns", map[string]TagSpec{
|
||||
"*": {Kind: MatchAny}, // triggers superset; omitted from canonical
|
||||
"a": {Kind: MatchNone},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := p.Key(), "ns::a[].*"; got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trim and validate", func(t *testing.T) {
|
||||
p, err := NewPattern(" ns ", map[string]TagSpec{
|
||||
" tag ": {Kind: MatchAny},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Key() != "ns::tag[*]" {
|
||||
t.Fatalf("unexpected canonical: %q", p.Key())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject invalid inputs", func(t *testing.T) {
|
||||
_, err := NewPattern("", nil, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty namespace")
|
||||
}
|
||||
_, err = NewPattern("ns", map[string]TagSpec{"": {Kind: MatchAny}}, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty tag")
|
||||
}
|
||||
_, err = NewPattern("ns", map[string]TagSpec{"bad:": {Kind: MatchAny}}, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ':' in tag")
|
||||
}
|
||||
_, err = NewPattern("ns", map[string]TagSpec{"a": {Kind: MatchExact, Params: map[string]string{"": "1"}}}, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty param key")
|
||||
}
|
||||
_, err = NewPattern("ns", map[string]TagSpec{"a": {Kind: MatchExact, Params: map[string]string{"k": "bad;val"}}}, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad param value")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MatchExact with empty params downgrades to []", func(t *testing.T) {
|
||||
// Behavior matches current constructor: empty exact => MatchNone
|
||||
p, err := NewPattern("ns", map[string]TagSpec{
|
||||
"a": {Kind: MatchExact, Params: map[string]string{}},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Key() != "ns::a[]" {
|
||||
t.Fatalf("unexpected canonical for empty exact: %q", p.Key())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPattern_Parse_Tokens_And_SupersetRecognition(t *testing.T) {
|
||||
t.Run("accept :* token and .*", func(t *testing.T) {
|
||||
ns, specs, sup, err := NewPatternFromRaw("ns::a[]:*:b[*]").Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ns != "ns" || !sup {
|
||||
t.Fatalf("ns=%q sup=%v", ns, sup)
|
||||
}
|
||||
if specs["a"].Kind != MatchNone || specs["b"].Kind != MatchAny {
|
||||
t.Fatalf("unexpected specs: %#v", specs)
|
||||
}
|
||||
|
||||
_, specs2, sup2, err := NewPatternFromRaw("ns::a[]:b[*].*").Parse()
|
||||
if err != nil || !sup2 {
|
||||
t.Fatalf("parse superset suffix failed: err=%v sup=%v", err, sup2)
|
||||
}
|
||||
if !reflect.DeepEqual(specs, specs2) {
|
||||
t.Fatalf("specs mismatch between forms")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("first-wins on duplicate tags and params", func(t *testing.T) {
|
||||
_, specs, sup, err := NewPatternFromRaw("ns::t[x=1;y=2]:t[*]:u[k=1;k=2]").Parse()
|
||||
if err != nil || sup {
|
||||
t.Fatalf("err=%v sup=%v", err, sup)
|
||||
}
|
||||
if specs["t"].Kind != MatchExact || specs["t"].Params["x"] != "1" {
|
||||
t.Fatalf("first tag should win: %#v", specs["t"])
|
||||
}
|
||||
if specs["u"].Params["k"] != "1" {
|
||||
t.Fatalf("first param key should win: %#v", specs["u"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject malformed", func(t *testing.T) {
|
||||
bads := []string{
|
||||
"", "no_ns", "ns:onecolon", "::missingns::tag[]",
|
||||
"ns::tag[", "ns::tag]", "ns::[]", "ns::tag[]junk",
|
||||
"ns::a[=1]", "ns::a[x=]", "ns::a[ x=1 ]",
|
||||
}
|
||||
for _, s := range bads {
|
||||
_, _, _, err := NewPatternFromRaw(s).Parse()
|
||||
if err == nil {
|
||||
t.Fatalf("expected parse error for %q", s)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPattern_Match_Matrix(t *testing.T) {
|
||||
makeID := func(key string) Identifier { return NewIdentifierFromRaw(key) }
|
||||
|
||||
t.Run("namespace mismatch", func(t *testing.T) {
|
||||
p := NewPatternFromRaw("ns::a[]")
|
||||
if p.Match(makeID("other::a[]")) {
|
||||
t.Fatal("should not match different namespace")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MatchAny accepts empty and nonempty", func(t *testing.T) {
|
||||
p := NewPatternFromRaw("ns::a[*]")
|
||||
if !p.Match(makeID("ns::a[]")) || !p.Match(makeID("ns::a[x=1]")) {
|
||||
t.Fatal("MatchAny should accept both")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MatchNone requires empty", func(t *testing.T) {
|
||||
p := NewPatternFromRaw("ns::a[]")
|
||||
if !p.Match(makeID("ns::a[]")) {
|
||||
t.Fatal("empty should match")
|
||||
}
|
||||
if p.Match(makeID("ns::a[x=1]")) {
|
||||
t.Fatal("nonempty should not match MatchNone")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MatchExact equals, order independent", func(t *testing.T) {
|
||||
p := NewPatternFromRaw("ns::a[x=1;y=2]")
|
||||
if !p.Match(makeID("ns::a[y=2;x=1]")) {
|
||||
t.Fatal("param order should not matter")
|
||||
}
|
||||
if p.Match(makeID("ns::a[x=1]")) {
|
||||
t.Fatal("missing param should fail")
|
||||
}
|
||||
if p.Match(makeID("ns::a[x=1;y=2;z=3]")) {
|
||||
t.Fatal("extra param should fail exact")
|
||||
}
|
||||
if p.Match(makeID("ns::a[x=9;y=2]")) {
|
||||
t.Fatal("different value should fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exact-set vs superset", func(t *testing.T) {
|
||||
exact := NewPatternFromRaw("ns::a[]:b[*]")
|
||||
super := NewPatternFromRaw("ns::a[]:b[*].*")
|
||||
|
||||
if !exact.Match(makeID("ns::a[].b[x=1]")) {
|
||||
t.Fatal("exact should match same set")
|
||||
}
|
||||
if exact.Match(makeID("ns::a[].b[x=1].c[]")) {
|
||||
t.Fatal("exact should not allow extra tags")
|
||||
}
|
||||
if !super.Match(makeID("ns::a[].b[x=1].c[]")) {
|
||||
t.Fatal("superset should allow extra tags")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all pattern tags must exist", func(t *testing.T) {
|
||||
p := NewPatternFromRaw("ns::a[]:b[*]")
|
||||
if p.Match(makeID("ns::a[]")) {
|
||||
t.Fatal("missing b should fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user