182 lines
5.0 KiB
Go
182 lines
5.0 KiB
Go
package domain
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
prefixRaw = "raw::"
|
|
prefixInternal = "internal::"
|
|
)
|
|
|
|
// Identifier is a canonical representation of a data stream identifier.
|
|
type Identifier struct{ key string }
|
|
|
|
func (id Identifier) IsRaw() bool { return strings.HasPrefix(id.key, prefixRaw) }
|
|
func (id Identifier) IsInternal() bool { return strings.HasPrefix(id.key, prefixInternal) }
|
|
func (id Identifier) Key() string { return id.key }
|
|
|
|
func (id Identifier) ProviderSubject() (provider, subject string, ok bool) {
|
|
if !id.IsRaw() {
|
|
return "", "", false
|
|
}
|
|
body := strings.TrimPrefix(id.key, prefixRaw)
|
|
prov, subj, ok := strings.Cut(body, ".")
|
|
return prov, subj, ok
|
|
}
|
|
|
|
func (id Identifier) InternalParts() (venue, stream, symbol string, params map[string]string, ok bool) {
|
|
if !id.IsInternal() {
|
|
return "", "", "", nil, false
|
|
}
|
|
body := strings.TrimPrefix(id.key, prefixInternal)
|
|
before, bracket, _ := strings.Cut(body, "[")
|
|
parts := strings.Split(before, ".")
|
|
if len(parts) != 3 {
|
|
return "", "", "", nil, false
|
|
}
|
|
return parts[0], parts[1], parts[2], decodeParams(strings.TrimSuffix(bracket, "]")), true
|
|
}
|
|
|
|
func RawID(provider, subject string) (Identifier, error) {
|
|
p := strings.ToLower(strings.TrimSpace(provider))
|
|
s := strings.TrimSpace(subject)
|
|
|
|
if err := validateComponent("provider", p, false); err != nil {
|
|
return Identifier{}, err
|
|
}
|
|
if err := validateComponent("subject", s, true); err != nil {
|
|
return Identifier{}, err
|
|
}
|
|
return Identifier{key: prefixRaw + p + "." + s}, nil
|
|
}
|
|
|
|
func InternalID(venue, stream, symbol string, params map[string]string) (Identifier, error) {
|
|
v := strings.ToLower(strings.TrimSpace(venue))
|
|
t := strings.ToLower(strings.TrimSpace(stream))
|
|
sym := strings.ToUpper(strings.TrimSpace(symbol))
|
|
|
|
if err := validateComponent("venue", v, false); err != nil {
|
|
return Identifier{}, err
|
|
}
|
|
if err := validateComponent("stream", t, false); err != nil {
|
|
return Identifier{}, err
|
|
}
|
|
if err := validateComponent("symbol", sym, false); err != nil {
|
|
return Identifier{}, err
|
|
}
|
|
|
|
paramStr, err := encodeParams(params) // "k=v;..." or ""
|
|
if err != nil {
|
|
return Identifier{}, err
|
|
}
|
|
if paramStr == "" {
|
|
paramStr = "[]"
|
|
} else {
|
|
paramStr = "[" + paramStr + "]"
|
|
}
|
|
return Identifier{key: prefixInternal + v + "." + t + "." + sym + paramStr}, nil
|
|
}
|
|
|
|
func ParseIdentifier(s string) (Identifier, error) {
|
|
s = strings.TrimSpace(s)
|
|
switch {
|
|
case strings.HasPrefix(s, prefixRaw):
|
|
// raw::provider.subject
|
|
body := strings.TrimPrefix(s, prefixRaw)
|
|
prov, subj, ok := strings.Cut(body, ".")
|
|
if !ok {
|
|
return Identifier{}, errors.New("invalid raw identifier: missing '.'")
|
|
}
|
|
return RawID(prov, subj)
|
|
|
|
case strings.HasPrefix(s, prefixInternal):
|
|
// internal::venue.stream.symbol[...]
|
|
body := strings.TrimPrefix(s, prefixInternal)
|
|
before, bracket, _ := strings.Cut(body, "[")
|
|
parts := strings.Split(before, ".")
|
|
if len(parts) != 3 {
|
|
return Identifier{}, errors.New("invalid internal identifier: need venue.stream.symbol")
|
|
}
|
|
params := decodeParams(strings.TrimSuffix(bracket, "]"))
|
|
return InternalID(parts[0], parts[1], parts[2], params)
|
|
}
|
|
return Identifier{}, errors.New("unknown identifier prefix")
|
|
}
|
|
|
|
var (
|
|
segDisallow = regexp.MustCompile(`[ \t\r\n\[\]]`) // forbid whitespace/brackets in fixed segments
|
|
dotDisallow = regexp.MustCompile(`[.]`) // fixed segments cannot contain '.'
|
|
)
|
|
|
|
// allowAny=true (for subject) skips dot checks but still forbids whitespace/brackets.
|
|
func validateComponent(name, v string, allowAny bool) error {
|
|
if v == "" {
|
|
return fmt.Errorf("%s cannot be empty", name)
|
|
}
|
|
if allowAny {
|
|
if segDisallow.MatchString(v) {
|
|
return fmt.Errorf("%s contains illegal chars [] or whitespace", name)
|
|
}
|
|
return nil
|
|
}
|
|
if segDisallow.MatchString(v) || dotDisallow.MatchString(v) {
|
|
return fmt.Errorf("%s contains illegal chars (dot/brackets/whitespace)", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// encodeParams renders sorted k=v pairs separated by ';'.
|
|
func encodeParams(params map[string]string) (string, error) {
|
|
if len(params) == 0 {
|
|
return "", nil
|
|
}
|
|
keys := make([]string, 0, len(params))
|
|
for k := range params {
|
|
k = strings.ToLower(strings.TrimSpace(k))
|
|
if k == "" {
|
|
continue
|
|
}
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
out := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
v := strings.TrimSpace(params[k])
|
|
// prevent breaking delimiters
|
|
if strings.ContainsAny(k, ";]") || strings.ContainsAny(v, ";]") {
|
|
return "", fmt.Errorf("param %q contains illegal ';' or ']'", k)
|
|
}
|
|
out = append(out, k+"="+v)
|
|
}
|
|
return strings.Join(out, ";"), nil
|
|
}
|
|
|
|
func decodeParams(s string) map[string]string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return map[string]string{}
|
|
}
|
|
out := make(map[string]string, 4)
|
|
for _, p := range strings.Split(s, ";") {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
kv := strings.SplitN(p, "=", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
k := strings.ToLower(strings.TrimSpace(kv[0]))
|
|
v := strings.TrimSpace(kv[1])
|
|
if k != "" {
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|