From 62bc8983c792e50c23c02b00ada36643abbae179 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 3 Jul 2026 15:30:44 +0000 Subject: [PATCH] Add pipeline module registries --- .../framework/pipeline/chunker_registry.go | 102 +++++ .../pipeline/chunker_registry_test.go | 383 ++++++++++++++++++ .../framework/pipeline/extractor_registry.go | 47 ++- .../pipeline/extractor_registry_test.go | 78 ++++ internal/framework/pipeline/input_registry.go | 47 ++- .../framework/pipeline/input_registry_test.go | 78 ++++ .../framework/pipeline/merger_registry.go | 102 +++++ .../pipeline/merger_registry_test.go | 58 +++ internal/framework/pipeline/module.go | 99 +++++ .../framework/pipeline/normalizer_registry.go | 102 +++++ .../pipeline/normalizer_registry_test.go | 58 +++ .../framework/pipeline/output_registry.go | 102 +++++ .../pipeline/output_registry_test.go | 58 +++ .../framework/pipeline/validator_registry.go | 102 +++++ .../pipeline/validator_registry_test.go | 58 +++ 15 files changed, 1446 insertions(+), 28 deletions(-) create mode 100644 internal/framework/pipeline/chunker_registry.go create mode 100644 internal/framework/pipeline/chunker_registry_test.go create mode 100644 internal/framework/pipeline/merger_registry.go create mode 100644 internal/framework/pipeline/merger_registry_test.go create mode 100644 internal/framework/pipeline/module.go create mode 100644 internal/framework/pipeline/normalizer_registry.go create mode 100644 internal/framework/pipeline/normalizer_registry_test.go create mode 100644 internal/framework/pipeline/output_registry.go create mode 100644 internal/framework/pipeline/output_registry_test.go create mode 100644 internal/framework/pipeline/validator_registry.go create mode 100644 internal/framework/pipeline/validator_registry_test.go diff --git a/internal/framework/pipeline/chunker_registry.go b/internal/framework/pipeline/chunker_registry.go new file mode 100644 index 0000000..3f133b6 --- /dev/null +++ b/internal/framework/pipeline/chunker_registry.go @@ -0,0 +1,102 @@ +package pipeline + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +type ChunkerConstructor func() (contracts.Chunker, error) + +type ChunkerRegistry struct { + constructors map[string]ChunkerConstructor + specs map[string]ModuleSpec +} + +func NewChunkerRegistry() *ChunkerRegistry { + return &ChunkerRegistry{ + constructors: make(map[string]ChunkerConstructor), + specs: make(map[string]ModuleSpec), + } +} + +func (r *ChunkerRegistry) Register(key string, constructor ChunkerConstructor) error { + return r.RegisterWithSpec(defaultModuleSpec(key, StageChunk), constructor) +} + +func (r *ChunkerRegistry) RegisterWithSpec(spec ModuleSpec, constructor ChunkerConstructor) error { + if r == nil { + return fmt.Errorf("chunker registry must not be nil") + } + + normalizedSpec := normalizeModuleSpec(spec) + if err := validateModuleSpec("chunker", StageChunk, normalizedSpec); err != nil { + return err + } + if constructor == nil { + return fmt.Errorf("chunker constructor for %q must not be nil", normalizedSpec.Key) + } + if _, ok := r.constructors[normalizedSpec.Key]; ok { + return fmt.Errorf("chunker %q is already registered", normalizedSpec.Key) + } + + if r.constructors == nil { + r.constructors = make(map[string]ChunkerConstructor) + } + if r.specs == nil { + r.specs = make(map[string]ModuleSpec) + } + r.constructors[normalizedSpec.Key] = constructor + r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) + return nil +} + +func (r *ChunkerRegistry) Build(key string) (contracts.Chunker, error) { + if r == nil { + return nil, fmt.Errorf("chunker registry must not be nil") + } + + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return nil, fmt.Errorf("chunker key must not be empty") + } + + constructor, ok := r.constructors[normalizedKey] + if !ok { + return nil, fmt.Errorf("chunker %q is not registered", normalizedKey) + } + + chunker, err := constructor() + if err != nil { + return nil, fmt.Errorf("build chunker %q: %w", normalizedKey, err) + } + if chunker == nil { + return nil, fmt.Errorf("chunker %q constructor returned nil", normalizedKey) + } + if chunker.Key() != normalizedKey { + return nil, fmt.Errorf("chunker %q returned key %q", normalizedKey, chunker.Key()) + } + + return chunker, nil +} + +func (r *ChunkerRegistry) Spec(key string) (ModuleSpec, bool) { + if r == nil { + return ModuleSpec{}, false + } + + spec, ok := r.specs[strings.TrimSpace(key)] + if !ok { + return ModuleSpec{}, false + } + return cloneModuleSpec(spec), true +} + +func (r *ChunkerRegistry) RegisteredKeys() []string { + if r == nil { + return nil + } + + return sortedRegistryKeys(r.constructors) +} diff --git a/internal/framework/pipeline/chunker_registry_test.go b/internal/framework/pipeline/chunker_registry_test.go new file mode 100644 index 0000000..48409ba --- /dev/null +++ b/internal/framework/pipeline/chunker_registry_test.go @@ -0,0 +1,383 @@ +package pipeline + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +type registryBehaviorCase[M any] struct { + name string + key string + stage ModuleStage + wrongStage ModuleStage + newRegistry func() any + register func(any, string, func() (M, error)) error + registerWithSpec func(any, ModuleSpec, func() (M, error)) error + build func(any, string) (M, error) + spec func(any, string) (ModuleSpec, bool) + registeredKeys func(any) []string + nilRegister func(string, func() (M, error)) error + nilBuild func(string) (M, error) + nilSpec func(string) (ModuleSpec, bool) + nilRegisteredKey func() []string + constructor func(string) func() (M, error) + moduleKey func(M) string +} + +func TestChunkerRegistryBehavior(t *testing.T) { + runRegistryBehaviorTests(t, registryBehaviorCase[contracts.Chunker]{ + name: "ChunkerRegistry", + key: "generic-chunker", + stage: StageChunk, + wrongStage: StageExtract, + newRegistry: func() any { + return NewChunkerRegistry() + }, + register: func(registry any, key string, constructor func() (contracts.Chunker, error)) error { + return registry.(*ChunkerRegistry).Register(key, constructor) + }, + registerWithSpec: func(registry any, spec ModuleSpec, constructor func() (contracts.Chunker, error)) error { + return registry.(*ChunkerRegistry).RegisterWithSpec(spec, constructor) + }, + build: func(registry any, key string) (contracts.Chunker, error) { + return registry.(*ChunkerRegistry).Build(key) + }, + spec: func(registry any, key string) (ModuleSpec, bool) { + return registry.(*ChunkerRegistry).Spec(key) + }, + registeredKeys: func(registry any) []string { + return registry.(*ChunkerRegistry).RegisteredKeys() + }, + nilRegister: func(key string, constructor func() (contracts.Chunker, error)) error { + var registry *ChunkerRegistry + return registry.Register(key, constructor) + }, + nilBuild: func(key string) (contracts.Chunker, error) { + var registry *ChunkerRegistry + return registry.Build(key) + }, + nilSpec: func(key string) (ModuleSpec, bool) { + var registry *ChunkerRegistry + return registry.Spec(key) + }, + nilRegisteredKey: func() []string { + var registry *ChunkerRegistry + return registry.RegisteredKeys() + }, + constructor: func(key string) func() (contracts.Chunker, error) { + return func() (contracts.Chunker, error) { + return registryChunker{key: key}, nil + } + }, + moduleKey: func(module contracts.Chunker) string { + return module.Key() + }, + }) +} + +func runRegistryBehaviorTests[M any](t *testing.T, testCase registryBehaviorCase[M]) { + t.Helper() + + t.Run(testCase.name+"/register and build", func(t *testing.T) { + registry := testCase.newRegistry() + if err := testCase.register(registry, testCase.key, testCase.constructor(testCase.key)); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + module, err := testCase.build(registry, testCase.key) + if err != nil { + t.Fatalf("Build() error = %v, want nil", err) + } + if got := testCase.moduleKey(module); got != testCase.key { + t.Fatalf("module key = %q, want %q", got, testCase.key) + } + }) + + t.Run(testCase.name+"/metadata registration and lookup", func(t *testing.T) { + registry := testCase.newRegistry() + spec := ModuleSpec{ + Key: " " + testCase.key + " ", + Stage: testCase.stage, + Provides: []string{" beta ", "alpha", "", "beta"}, + Requires: []string{" source ", "source", ""}, + } + if err := testCase.registerWithSpec(registry, spec, testCase.constructor(testCase.key)); err != nil { + t.Fatalf("RegisterWithSpec() error = %v, want nil", err) + } + + got, ok := testCase.spec(registry, " "+testCase.key+"\n") + if !ok { + t.Fatal("Spec() ok = false, want true") + } + want := ModuleSpec{ + Key: testCase.key, + Stage: testCase.stage, + Provides: []string{"alpha", "beta"}, + Requires: []string{"source"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Spec() = %#v, want %#v", got, want) + } + + got.Provides[0] = "changed" + again, ok := testCase.spec(registry, testCase.key) + if !ok { + t.Fatal("Spec() after caller mutation ok = false, want true") + } + if !reflect.DeepEqual(again, want) { + t.Fatalf("Spec() after caller mutation = %#v, want %#v", again, want) + } + }) + + t.Run(testCase.name+"/default spec from register", func(t *testing.T) { + registry := testCase.newRegistry() + if err := testCase.register(registry, " "+testCase.key+" ", testCase.constructor(testCase.key)); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + spec, ok := testCase.spec(registry, testCase.key) + if !ok { + t.Fatal("Spec() ok = false, want true") + } + want := ModuleSpec{Key: testCase.key, Stage: testCase.stage} + if !reflect.DeepEqual(spec, want) { + t.Fatalf("Spec() = %#v, want %#v", spec, want) + } + }) + + t.Run(testCase.name+"/wrong stage rejection", func(t *testing.T) { + registry := testCase.newRegistry() + err := testCase.registerWithSpec(registry, ModuleSpec{Key: testCase.key, Stage: testCase.wrongStage}, testCase.constructor(testCase.key)) + if err == nil { + t.Fatal("RegisterWithSpec() error = nil, want error") + } + if !strings.Contains(err.Error(), "stage") { + t.Fatalf("RegisterWithSpec() error = %q, want stage error", err.Error()) + } + }) + + t.Run(testCase.name+"/key trimming", func(t *testing.T) { + registry := testCase.newRegistry() + if err := testCase.register(registry, " "+testCase.key+" ", testCase.constructor(testCase.key)); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + module, err := testCase.build(registry, "\t"+testCase.key+"\n") + if err != nil { + t.Fatalf("Build() error = %v, want nil", err) + } + if got := testCase.moduleKey(module); got != testCase.key { + t.Fatalf("module key = %q, want %q", got, testCase.key) + } + }) + + t.Run(testCase.name+"/empty key rejection", func(t *testing.T) { + registry := testCase.newRegistry() + err := testCase.register(registry, " \t", testCase.constructor(testCase.key)) + if err == nil { + t.Fatal("Register() error = nil, want error") + } + if !strings.Contains(err.Error(), "key must not be empty") { + t.Fatalf("Register() error = %q, want empty key error", err.Error()) + } + }) + + t.Run(testCase.name+"/duplicate key rejection", func(t *testing.T) { + registry := testCase.newRegistry() + if err := testCase.register(registry, testCase.key, testCase.constructor(testCase.key)); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + err := testCase.register(registry, " "+testCase.key+" ", testCase.constructor(testCase.key)) + if err == nil { + t.Fatal("Register() error = nil, want error") + } + if !strings.Contains(err.Error(), "already registered") { + t.Fatalf("Register() error = %q, want duplicate key error", err.Error()) + } + }) + + t.Run(testCase.name+"/nil constructor rejection", func(t *testing.T) { + registry := testCase.newRegistry() + err := testCase.register(registry, testCase.key, nil) + if err == nil { + t.Fatal("Register() error = nil, want error") + } + if !strings.Contains(err.Error(), "constructor") { + t.Fatalf("Register() error = %q, want constructor error", err.Error()) + } + }) + + t.Run(testCase.name+"/unknown key build error", func(t *testing.T) { + registry := testCase.newRegistry() + _, err := testCase.build(registry, "missing") + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "not registered") { + t.Fatalf("Build() error = %q, want unknown key error", err.Error()) + } + }) + + t.Run(testCase.name+"/constructor error wrapping", func(t *testing.T) { + registry := testCase.newRegistry() + constructorErr := errors.New("constructor failed") + if err := testCase.register(registry, testCase.key, func() (M, error) { + var zero M + return zero, constructorErr + }); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + _, err := testCase.build(registry, testCase.key) + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !errors.Is(err, constructorErr) { + t.Fatalf("Build() error = %v, want wrapped constructor error", err) + } + if !strings.Contains(err.Error(), testCase.key) { + t.Fatalf("Build() error = %q, want key context", err.Error()) + } + }) + + t.Run(testCase.name+"/nil module rejection", func(t *testing.T) { + registry := testCase.newRegistry() + if err := testCase.register(registry, testCase.key, func() (M, error) { + var zero M + return zero, nil + }); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + _, err := testCase.build(registry, testCase.key) + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "returned nil") { + t.Fatalf("Build() error = %q, want nil module error", err.Error()) + } + }) + + t.Run(testCase.name+"/key mismatch rejection", func(t *testing.T) { + registry := testCase.newRegistry() + if err := testCase.register(registry, testCase.key, testCase.constructor("other")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + _, err := testCase.build(registry, testCase.key) + if err == nil { + t.Fatal("Build() error = nil, want error") + } + if !strings.Contains(err.Error(), "returned") { + t.Fatalf("Build() error = %q, want mismatch error", err.Error()) + } + }) + + t.Run(testCase.name+"/sorted registered keys", func(t *testing.T) { + registry := testCase.newRegistry() + for _, key := range []string{"zeta", "alpha", "middle"} { + if err := testCase.register(registry, key, testCase.constructor(key)); err != nil { + t.Fatalf("Register(%q) error = %v, want nil", key, err) + } + } + + keys := testCase.registeredKeys(registry) + want := []string{"alpha", "middle", "zeta"} + if !reflect.DeepEqual(keys, want) { + t.Fatalf("RegisteredKeys() = %#v, want %#v", keys, want) + } + + keys[0] = "changed" + if got := testCase.registeredKeys(registry); !reflect.DeepEqual(got, want) { + t.Fatalf("RegisteredKeys() after caller mutation = %#v, want %#v", got, want) + } + }) + + t.Run(testCase.name+"/nil registry behavior", func(t *testing.T) { + if err := testCase.nilRegister(testCase.key, testCase.constructor(testCase.key)); err == nil { + t.Fatal("Register() error = nil, want error") + } + if _, err := testCase.nilBuild(testCase.key); err == nil { + t.Fatal("Build() error = nil, want error") + } + if _, ok := testCase.nilSpec(testCase.key); ok { + t.Fatal("Spec() ok = true, want false") + } + if keys := testCase.nilRegisteredKey(); keys != nil { + t.Fatalf("RegisteredKeys() = %#v, want nil", keys) + } + }) + + t.Run(testCase.name+"/unknown spec lookup", func(t *testing.T) { + registry := testCase.newRegistry() + if _, ok := testCase.spec(registry, "missing"); ok { + t.Fatal("Spec() ok = true, want false") + } + }) +} + +type registryChunker struct { + key string +} + +func (chunker registryChunker) Key() string { + return chunker.key +} + +func (chunker registryChunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkResult, error) { + return contracts.ChunkResult{}, nil +} + +type registryMerger struct { + key string +} + +func (merger registryMerger) Key() string { + return merger.key +} + +func (merger registryMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) { + return contracts.MergeResult{}, nil +} + +type registryNormalizer struct { + key string +} + +func (normalizer registryNormalizer) Key() string { + return normalizer.key +} + +func (normalizer registryNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { + return contracts.NormalizeResult{}, nil +} + +type registryOutputEncoder struct { + key string +} + +func (encoder registryOutputEncoder) Key() string { + return encoder.key +} + +func (encoder registryOutputEncoder) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) { + return contracts.OutputResult{}, nil +} + +type registryValidator struct { + name string +} + +func (validator registryValidator) Name() string { + return validator.name +} + +func (validator registryValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { + return contracts.ValidationResult{}, nil +} diff --git a/internal/framework/pipeline/extractor_registry.go b/internal/framework/pipeline/extractor_registry.go index 4bda2ce..0151d6d 100644 --- a/internal/framework/pipeline/extractor_registry.go +++ b/internal/framework/pipeline/extractor_registry.go @@ -2,7 +2,6 @@ package pipeline import ( "fmt" - "sort" "strings" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" @@ -12,31 +11,44 @@ type ExtractorConstructor func() (contracts.Extractor, error) type ExtractorRegistry struct { constructors map[string]ExtractorConstructor + specs map[string]ModuleSpec } func NewExtractorRegistry() *ExtractorRegistry { return &ExtractorRegistry{ constructors: make(map[string]ExtractorConstructor), + specs: make(map[string]ModuleSpec), } } func (r *ExtractorRegistry) Register(key string, constructor ExtractorConstructor) error { + return r.RegisterWithSpec(defaultModuleSpec(key, StageExtract), constructor) +} + +func (r *ExtractorRegistry) RegisterWithSpec(spec ModuleSpec, constructor ExtractorConstructor) error { if r == nil { return fmt.Errorf("extractor registry must not be nil") } - normalizedKey := strings.TrimSpace(key) - if normalizedKey == "" { - return fmt.Errorf("extractor key must not be empty") + normalizedSpec := normalizeModuleSpec(spec) + if err := validateModuleSpec("extractor", StageExtract, normalizedSpec); err != nil { + return err } if constructor == nil { - return fmt.Errorf("extractor constructor for %q must not be nil", normalizedKey) + return fmt.Errorf("extractor constructor for %q must not be nil", normalizedSpec.Key) } - if _, ok := r.constructors[normalizedKey]; ok { - return fmt.Errorf("extractor %q is already registered", normalizedKey) + if _, ok := r.constructors[normalizedSpec.Key]; ok { + return fmt.Errorf("extractor %q is already registered", normalizedSpec.Key) } - r.constructors[normalizedKey] = constructor + if r.constructors == nil { + r.constructors = make(map[string]ExtractorConstructor) + } + if r.specs == nil { + r.specs = make(map[string]ModuleSpec) + } + r.constructors[normalizedSpec.Key] = constructor + r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) return nil } @@ -69,15 +81,22 @@ func (r *ExtractorRegistry) Build(key string) (contracts.Extractor, error) { return extractor, nil } +func (r *ExtractorRegistry) Spec(key string) (ModuleSpec, bool) { + if r == nil { + return ModuleSpec{}, false + } + + spec, ok := r.specs[strings.TrimSpace(key)] + if !ok { + return ModuleSpec{}, false + } + return cloneModuleSpec(spec), true +} + func (r *ExtractorRegistry) RegisteredKeys() []string { if r == nil { return nil } - keys := make([]string, 0, len(r.constructors)) - for key := range r.constructors { - keys = append(keys, key) - } - sort.Strings(keys) - return keys + return sortedRegistryKeys(r.constructors) } diff --git a/internal/framework/pipeline/extractor_registry_test.go b/internal/framework/pipeline/extractor_registry_test.go index 8c49b58..79f3081 100644 --- a/internal/framework/pipeline/extractor_registry_test.go +++ b/internal/framework/pipeline/extractor_registry_test.go @@ -42,6 +42,81 @@ func TestExtractorRegistryRegisterAndBuildTrimKeys(t *testing.T) { } } +func TestExtractorRegistryRegisterWithSpecStoresMetadata(t *testing.T) { + registry := NewExtractorRegistry() + spec := ModuleSpec{ + Key: " generic-extractor ", + Stage: StageExtract, + Provides: []string{" generic-artifact ", "source-citations", "generic-artifact", ""}, + Requires: []string{" source-document ", "source-document", ""}, + } + + if err := registry.RegisterWithSpec(spec, fakeExtractorConstructor("generic-extractor")); err != nil { + t.Fatalf("RegisterWithSpec() error = %v, want nil", err) + } + + got, ok := registry.Spec("\tgeneric-extractor\n") + if !ok { + t.Fatal("Spec() ok = false, want true") + } + want := ModuleSpec{ + Key: "generic-extractor", + Stage: StageExtract, + Provides: []string{"generic-artifact", "source-citations"}, + Requires: []string{"source-document"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Spec() = %#v, want %#v", got, want) + } + + got.Provides[0] = "changed" + again, ok := registry.Spec("generic-extractor") + if !ok { + t.Fatal("Spec() after caller mutation ok = false, want true") + } + if !reflect.DeepEqual(again, want) { + t.Fatalf("Spec() after caller mutation = %#v, want %#v", again, want) + } +} + +func TestExtractorRegistryRegisterStoresDefaultSpec(t *testing.T) { + registry := NewExtractorRegistry() + + if err := registry.Register(" generic-extractor ", fakeExtractorConstructor("generic-extractor")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + got, ok := registry.Spec("generic-extractor") + if !ok { + t.Fatal("Spec() ok = false, want true") + } + want := ModuleSpec{Key: "generic-extractor", Stage: StageExtract} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Spec() = %#v, want %#v", got, want) + } +} + +func TestExtractorRegistryRegisterWithSpecRejectsWrongStage(t *testing.T) { + registry := NewExtractorRegistry() + + err := registry.RegisterWithSpec(ModuleSpec{Key: "generic-extractor", Stage: StageInput}, fakeExtractorConstructor("generic-extractor")) + + if err == nil { + t.Fatal("RegisterWithSpec() error = nil, want error") + } + if !strings.Contains(err.Error(), "stage") { + t.Fatalf("RegisterWithSpec() error = %q, want stage error", err.Error()) + } +} + +func TestExtractorRegistrySpecRejectsUnknownKey(t *testing.T) { + registry := NewExtractorRegistry() + + if _, ok := registry.Spec("missing-extractor"); ok { + t.Fatal("Spec() ok = true, want false") + } +} + func TestExtractorRegistryRegisterRejectsEmptyKey(t *testing.T) { registry := NewExtractorRegistry() @@ -183,6 +258,9 @@ func TestExtractorRegistryNilRegistryBehavior(t *testing.T) { if _, err := registry.Build("generic-extractor"); err == nil { t.Fatal("Build() error = nil, want error") } + if _, ok := registry.Spec("generic-extractor"); ok { + t.Fatal("Spec() ok = true, want false") + } if keys := registry.RegisteredKeys(); keys != nil { t.Fatalf("RegisteredKeys() = %#v, want nil", keys) } diff --git a/internal/framework/pipeline/input_registry.go b/internal/framework/pipeline/input_registry.go index 099a6d2..9a6d5a7 100644 --- a/internal/framework/pipeline/input_registry.go +++ b/internal/framework/pipeline/input_registry.go @@ -2,7 +2,6 @@ package pipeline import ( "fmt" - "sort" "strings" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" @@ -12,31 +11,44 @@ type InputAdapterConstructor func() (contracts.InputAdapter, error) type InputAdapterRegistry struct { constructors map[string]InputAdapterConstructor + specs map[string]ModuleSpec } func NewInputAdapterRegistry() *InputAdapterRegistry { return &InputAdapterRegistry{ constructors: make(map[string]InputAdapterConstructor), + specs: make(map[string]ModuleSpec), } } func (r *InputAdapterRegistry) Register(key string, constructor InputAdapterConstructor) error { + return r.RegisterWithSpec(defaultModuleSpec(key, StageInput), constructor) +} + +func (r *InputAdapterRegistry) RegisterWithSpec(spec ModuleSpec, constructor InputAdapterConstructor) error { if r == nil { return fmt.Errorf("input adapter registry must not be nil") } - normalizedKey := strings.TrimSpace(key) - if normalizedKey == "" { - return fmt.Errorf("input adapter key must not be empty") + normalizedSpec := normalizeModuleSpec(spec) + if err := validateModuleSpec("input adapter", StageInput, normalizedSpec); err != nil { + return err } if constructor == nil { - return fmt.Errorf("input adapter constructor for %q must not be nil", normalizedKey) + return fmt.Errorf("input adapter constructor for %q must not be nil", normalizedSpec.Key) } - if _, ok := r.constructors[normalizedKey]; ok { - return fmt.Errorf("input adapter %q is already registered", normalizedKey) + if _, ok := r.constructors[normalizedSpec.Key]; ok { + return fmt.Errorf("input adapter %q is already registered", normalizedSpec.Key) } - r.constructors[normalizedKey] = constructor + if r.constructors == nil { + r.constructors = make(map[string]InputAdapterConstructor) + } + if r.specs == nil { + r.specs = make(map[string]ModuleSpec) + } + r.constructors[normalizedSpec.Key] = constructor + r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) return nil } @@ -69,15 +81,22 @@ func (r *InputAdapterRegistry) Build(key string) (contracts.InputAdapter, error) return adapter, nil } +func (r *InputAdapterRegistry) Spec(key string) (ModuleSpec, bool) { + if r == nil { + return ModuleSpec{}, false + } + + spec, ok := r.specs[strings.TrimSpace(key)] + if !ok { + return ModuleSpec{}, false + } + return cloneModuleSpec(spec), true +} + func (r *InputAdapterRegistry) RegisteredKeys() []string { if r == nil { return nil } - keys := make([]string, 0, len(r.constructors)) - for key := range r.constructors { - keys = append(keys, key) - } - sort.Strings(keys) - return keys + return sortedRegistryKeys(r.constructors) } diff --git a/internal/framework/pipeline/input_registry_test.go b/internal/framework/pipeline/input_registry_test.go index a24c838..dd33c1f 100644 --- a/internal/framework/pipeline/input_registry_test.go +++ b/internal/framework/pipeline/input_registry_test.go @@ -43,6 +43,81 @@ func TestInputAdapterRegistryRegisterAndBuildTrimKeys(t *testing.T) { } } +func TestInputAdapterRegistryRegisterWithSpecStoresMetadata(t *testing.T) { + registry := NewInputAdapterRegistry() + spec := ModuleSpec{ + Key: " generic-input ", + Stage: StageInput, + Provides: []string{" parsed-source ", "source-document", "parsed-source", ""}, + Requires: []string{" raw-bytes ", "raw-bytes", ""}, + } + + if err := registry.RegisterWithSpec(spec, fakeInputAdapterConstructor("generic-input")); err != nil { + t.Fatalf("RegisterWithSpec() error = %v, want nil", err) + } + + got, ok := registry.Spec("\tgeneric-input\n") + if !ok { + t.Fatal("Spec() ok = false, want true") + } + want := ModuleSpec{ + Key: "generic-input", + Stage: StageInput, + Provides: []string{"parsed-source", "source-document"}, + Requires: []string{"raw-bytes"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Spec() = %#v, want %#v", got, want) + } + + got.Provides[0] = "changed" + again, ok := registry.Spec("generic-input") + if !ok { + t.Fatal("Spec() after caller mutation ok = false, want true") + } + if !reflect.DeepEqual(again, want) { + t.Fatalf("Spec() after caller mutation = %#v, want %#v", again, want) + } +} + +func TestInputAdapterRegistryRegisterStoresDefaultSpec(t *testing.T) { + registry := NewInputAdapterRegistry() + + if err := registry.Register(" generic-input ", fakeInputAdapterConstructor("generic-input")); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + + got, ok := registry.Spec("generic-input") + if !ok { + t.Fatal("Spec() ok = false, want true") + } + want := ModuleSpec{Key: "generic-input", Stage: StageInput} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Spec() = %#v, want %#v", got, want) + } +} + +func TestInputAdapterRegistryRegisterWithSpecRejectsWrongStage(t *testing.T) { + registry := NewInputAdapterRegistry() + + err := registry.RegisterWithSpec(ModuleSpec{Key: "generic-input", Stage: StageExtract}, fakeInputAdapterConstructor("generic-input")) + + if err == nil { + t.Fatal("RegisterWithSpec() error = nil, want error") + } + if !strings.Contains(err.Error(), "stage") { + t.Fatalf("RegisterWithSpec() error = %q, want stage error", err.Error()) + } +} + +func TestInputAdapterRegistrySpecRejectsUnknownKey(t *testing.T) { + registry := NewInputAdapterRegistry() + + if _, ok := registry.Spec("missing-input"); ok { + t.Fatal("Spec() ok = true, want false") + } +} + func TestInputAdapterRegistryRegisterRejectsEmptyKey(t *testing.T) { registry := NewInputAdapterRegistry() @@ -184,6 +259,9 @@ func TestInputAdapterRegistryNilRegistryBehavior(t *testing.T) { if _, err := registry.Build("generic-input"); err == nil { t.Fatal("Build() error = nil, want error") } + if _, ok := registry.Spec("generic-input"); ok { + t.Fatal("Spec() ok = true, want false") + } if keys := registry.RegisteredKeys(); keys != nil { t.Fatalf("RegisteredKeys() = %#v, want nil", keys) } diff --git a/internal/framework/pipeline/merger_registry.go b/internal/framework/pipeline/merger_registry.go new file mode 100644 index 0000000..14c1b98 --- /dev/null +++ b/internal/framework/pipeline/merger_registry.go @@ -0,0 +1,102 @@ +package pipeline + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +type MergerConstructor func() (contracts.Merger, error) + +type MergerRegistry struct { + constructors map[string]MergerConstructor + specs map[string]ModuleSpec +} + +func NewMergerRegistry() *MergerRegistry { + return &MergerRegistry{ + constructors: make(map[string]MergerConstructor), + specs: make(map[string]ModuleSpec), + } +} + +func (r *MergerRegistry) Register(key string, constructor MergerConstructor) error { + return r.RegisterWithSpec(defaultModuleSpec(key, StageMerge), constructor) +} + +func (r *MergerRegistry) RegisterWithSpec(spec ModuleSpec, constructor MergerConstructor) error { + if r == nil { + return fmt.Errorf("merger registry must not be nil") + } + + normalizedSpec := normalizeModuleSpec(spec) + if err := validateModuleSpec("merger", StageMerge, normalizedSpec); err != nil { + return err + } + if constructor == nil { + return fmt.Errorf("merger constructor for %q must not be nil", normalizedSpec.Key) + } + if _, ok := r.constructors[normalizedSpec.Key]; ok { + return fmt.Errorf("merger %q is already registered", normalizedSpec.Key) + } + + if r.constructors == nil { + r.constructors = make(map[string]MergerConstructor) + } + if r.specs == nil { + r.specs = make(map[string]ModuleSpec) + } + r.constructors[normalizedSpec.Key] = constructor + r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) + return nil +} + +func (r *MergerRegistry) Build(key string) (contracts.Merger, error) { + if r == nil { + return nil, fmt.Errorf("merger registry must not be nil") + } + + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return nil, fmt.Errorf("merger key must not be empty") + } + + constructor, ok := r.constructors[normalizedKey] + if !ok { + return nil, fmt.Errorf("merger %q is not registered", normalizedKey) + } + + merger, err := constructor() + if err != nil { + return nil, fmt.Errorf("build merger %q: %w", normalizedKey, err) + } + if merger == nil { + return nil, fmt.Errorf("merger %q constructor returned nil", normalizedKey) + } + if merger.Key() != normalizedKey { + return nil, fmt.Errorf("merger %q returned key %q", normalizedKey, merger.Key()) + } + + return merger, nil +} + +func (r *MergerRegistry) Spec(key string) (ModuleSpec, bool) { + if r == nil { + return ModuleSpec{}, false + } + + spec, ok := r.specs[strings.TrimSpace(key)] + if !ok { + return ModuleSpec{}, false + } + return cloneModuleSpec(spec), true +} + +func (r *MergerRegistry) RegisteredKeys() []string { + if r == nil { + return nil + } + + return sortedRegistryKeys(r.constructors) +} diff --git a/internal/framework/pipeline/merger_registry_test.go b/internal/framework/pipeline/merger_registry_test.go new file mode 100644 index 0000000..2af4f24 --- /dev/null +++ b/internal/framework/pipeline/merger_registry_test.go @@ -0,0 +1,58 @@ +package pipeline + +import ( + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestMergerRegistryBehavior(t *testing.T) { + runRegistryBehaviorTests(t, registryBehaviorCase[contracts.Merger]{ + name: "MergerRegistry", + key: "generic-merger", + stage: StageMerge, + wrongStage: StageExtract, + newRegistry: func() any { + return NewMergerRegistry() + }, + register: func(registry any, key string, constructor func() (contracts.Merger, error)) error { + return registry.(*MergerRegistry).Register(key, constructor) + }, + registerWithSpec: func(registry any, spec ModuleSpec, constructor func() (contracts.Merger, error)) error { + return registry.(*MergerRegistry).RegisterWithSpec(spec, constructor) + }, + build: func(registry any, key string) (contracts.Merger, error) { + return registry.(*MergerRegistry).Build(key) + }, + spec: func(registry any, key string) (ModuleSpec, bool) { + return registry.(*MergerRegistry).Spec(key) + }, + registeredKeys: func(registry any) []string { + return registry.(*MergerRegistry).RegisteredKeys() + }, + nilRegister: func(key string, constructor func() (contracts.Merger, error)) error { + var registry *MergerRegistry + return registry.Register(key, constructor) + }, + nilBuild: func(key string) (contracts.Merger, error) { + var registry *MergerRegistry + return registry.Build(key) + }, + nilSpec: func(key string) (ModuleSpec, bool) { + var registry *MergerRegistry + return registry.Spec(key) + }, + nilRegisteredKey: func() []string { + var registry *MergerRegistry + return registry.RegisteredKeys() + }, + constructor: func(key string) func() (contracts.Merger, error) { + return func() (contracts.Merger, error) { + return registryMerger{key: key}, nil + } + }, + moduleKey: func(module contracts.Merger) string { + return module.Key() + }, + }) +} diff --git a/internal/framework/pipeline/module.go b/internal/framework/pipeline/module.go new file mode 100644 index 0000000..ff86972 --- /dev/null +++ b/internal/framework/pipeline/module.go @@ -0,0 +1,99 @@ +package pipeline + +import ( + "fmt" + "sort" + "strings" +) + +type ModuleStage string + +const ( + StageInput ModuleStage = "input" + StageChunk ModuleStage = "chunk" + StageExtract ModuleStage = "extract" + StageMerge ModuleStage = "merge" + StageNormalize ModuleStage = "normalize" + StageValidate ModuleStage = "validate" + StageOutput ModuleStage = "output" +) + +type ModuleSpec struct { + Key string + Stage ModuleStage + Provides []string + Requires []string +} + +func defaultModuleSpec(key string, stage ModuleStage) ModuleSpec { + return ModuleSpec{ + Key: key, + Stage: stage, + } +} + +func normalizeModuleSpec(spec ModuleSpec) ModuleSpec { + return ModuleSpec{ + Key: strings.TrimSpace(spec.Key), + Stage: spec.Stage, + Provides: normalizeCapabilities(spec.Provides), + Requires: normalizeCapabilities(spec.Requires), + } +} + +func normalizeCapabilities(values []string) []string { + if len(values) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + normalized := strings.TrimSpace(value) + if normalized == "" { + continue + } + seen[normalized] = struct{}{} + } + if len(seen) == 0 { + return nil + } + + capabilities := make([]string, 0, len(seen)) + for value := range seen { + capabilities = append(capabilities, value) + } + sort.Strings(capabilities) + return capabilities +} + +func cloneModuleSpec(spec ModuleSpec) ModuleSpec { + return ModuleSpec{ + Key: spec.Key, + Stage: spec.Stage, + Provides: append([]string(nil), spec.Provides...), + Requires: append([]string(nil), spec.Requires...), + } +} + +func validateModuleSpec(kind string, expectedStage ModuleStage, spec ModuleSpec) error { + if spec.Key == "" { + return fmt.Errorf("%s key must not be empty", kind) + } + if spec.Stage != expectedStage { + return fmt.Errorf("%s %q must use %q stage, got %q", kind, spec.Key, expectedStage, spec.Stage) + } + return nil +} + +func sortedRegistryKeys[C any](constructors map[string]C) []string { + if len(constructors) == 0 { + return nil + } + + keys := make([]string, 0, len(constructors)) + for key := range constructors { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/framework/pipeline/normalizer_registry.go b/internal/framework/pipeline/normalizer_registry.go new file mode 100644 index 0000000..6b19aa6 --- /dev/null +++ b/internal/framework/pipeline/normalizer_registry.go @@ -0,0 +1,102 @@ +package pipeline + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +type NormalizerConstructor func() (contracts.Normalizer, error) + +type NormalizerRegistry struct { + constructors map[string]NormalizerConstructor + specs map[string]ModuleSpec +} + +func NewNormalizerRegistry() *NormalizerRegistry { + return &NormalizerRegistry{ + constructors: make(map[string]NormalizerConstructor), + specs: make(map[string]ModuleSpec), + } +} + +func (r *NormalizerRegistry) Register(key string, constructor NormalizerConstructor) error { + return r.RegisterWithSpec(defaultModuleSpec(key, StageNormalize), constructor) +} + +func (r *NormalizerRegistry) RegisterWithSpec(spec ModuleSpec, constructor NormalizerConstructor) error { + if r == nil { + return fmt.Errorf("normalizer registry must not be nil") + } + + normalizedSpec := normalizeModuleSpec(spec) + if err := validateModuleSpec("normalizer", StageNormalize, normalizedSpec); err != nil { + return err + } + if constructor == nil { + return fmt.Errorf("normalizer constructor for %q must not be nil", normalizedSpec.Key) + } + if _, ok := r.constructors[normalizedSpec.Key]; ok { + return fmt.Errorf("normalizer %q is already registered", normalizedSpec.Key) + } + + if r.constructors == nil { + r.constructors = make(map[string]NormalizerConstructor) + } + if r.specs == nil { + r.specs = make(map[string]ModuleSpec) + } + r.constructors[normalizedSpec.Key] = constructor + r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) + return nil +} + +func (r *NormalizerRegistry) Build(key string) (contracts.Normalizer, error) { + if r == nil { + return nil, fmt.Errorf("normalizer registry must not be nil") + } + + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return nil, fmt.Errorf("normalizer key must not be empty") + } + + constructor, ok := r.constructors[normalizedKey] + if !ok { + return nil, fmt.Errorf("normalizer %q is not registered", normalizedKey) + } + + normalizer, err := constructor() + if err != nil { + return nil, fmt.Errorf("build normalizer %q: %w", normalizedKey, err) + } + if normalizer == nil { + return nil, fmt.Errorf("normalizer %q constructor returned nil", normalizedKey) + } + if normalizer.Key() != normalizedKey { + return nil, fmt.Errorf("normalizer %q returned key %q", normalizedKey, normalizer.Key()) + } + + return normalizer, nil +} + +func (r *NormalizerRegistry) Spec(key string) (ModuleSpec, bool) { + if r == nil { + return ModuleSpec{}, false + } + + spec, ok := r.specs[strings.TrimSpace(key)] + if !ok { + return ModuleSpec{}, false + } + return cloneModuleSpec(spec), true +} + +func (r *NormalizerRegistry) RegisteredKeys() []string { + if r == nil { + return nil + } + + return sortedRegistryKeys(r.constructors) +} diff --git a/internal/framework/pipeline/normalizer_registry_test.go b/internal/framework/pipeline/normalizer_registry_test.go new file mode 100644 index 0000000..edc7978 --- /dev/null +++ b/internal/framework/pipeline/normalizer_registry_test.go @@ -0,0 +1,58 @@ +package pipeline + +import ( + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestNormalizerRegistryBehavior(t *testing.T) { + runRegistryBehaviorTests(t, registryBehaviorCase[contracts.Normalizer]{ + name: "NormalizerRegistry", + key: "generic-normalizer", + stage: StageNormalize, + wrongStage: StageExtract, + newRegistry: func() any { + return NewNormalizerRegistry() + }, + register: func(registry any, key string, constructor func() (contracts.Normalizer, error)) error { + return registry.(*NormalizerRegistry).Register(key, constructor) + }, + registerWithSpec: func(registry any, spec ModuleSpec, constructor func() (contracts.Normalizer, error)) error { + return registry.(*NormalizerRegistry).RegisterWithSpec(spec, constructor) + }, + build: func(registry any, key string) (contracts.Normalizer, error) { + return registry.(*NormalizerRegistry).Build(key) + }, + spec: func(registry any, key string) (ModuleSpec, bool) { + return registry.(*NormalizerRegistry).Spec(key) + }, + registeredKeys: func(registry any) []string { + return registry.(*NormalizerRegistry).RegisteredKeys() + }, + nilRegister: func(key string, constructor func() (contracts.Normalizer, error)) error { + var registry *NormalizerRegistry + return registry.Register(key, constructor) + }, + nilBuild: func(key string) (contracts.Normalizer, error) { + var registry *NormalizerRegistry + return registry.Build(key) + }, + nilSpec: func(key string) (ModuleSpec, bool) { + var registry *NormalizerRegistry + return registry.Spec(key) + }, + nilRegisteredKey: func() []string { + var registry *NormalizerRegistry + return registry.RegisteredKeys() + }, + constructor: func(key string) func() (contracts.Normalizer, error) { + return func() (contracts.Normalizer, error) { + return registryNormalizer{key: key}, nil + } + }, + moduleKey: func(module contracts.Normalizer) string { + return module.Key() + }, + }) +} diff --git a/internal/framework/pipeline/output_registry.go b/internal/framework/pipeline/output_registry.go new file mode 100644 index 0000000..d63126f --- /dev/null +++ b/internal/framework/pipeline/output_registry.go @@ -0,0 +1,102 @@ +package pipeline + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +type OutputEncoderConstructor func() (contracts.OutputEncoder, error) + +type OutputEncoderRegistry struct { + constructors map[string]OutputEncoderConstructor + specs map[string]ModuleSpec +} + +func NewOutputEncoderRegistry() *OutputEncoderRegistry { + return &OutputEncoderRegistry{ + constructors: make(map[string]OutputEncoderConstructor), + specs: make(map[string]ModuleSpec), + } +} + +func (r *OutputEncoderRegistry) Register(key string, constructor OutputEncoderConstructor) error { + return r.RegisterWithSpec(defaultModuleSpec(key, StageOutput), constructor) +} + +func (r *OutputEncoderRegistry) RegisterWithSpec(spec ModuleSpec, constructor OutputEncoderConstructor) error { + if r == nil { + return fmt.Errorf("output encoder registry must not be nil") + } + + normalizedSpec := normalizeModuleSpec(spec) + if err := validateModuleSpec("output encoder", StageOutput, normalizedSpec); err != nil { + return err + } + if constructor == nil { + return fmt.Errorf("output encoder constructor for %q must not be nil", normalizedSpec.Key) + } + if _, ok := r.constructors[normalizedSpec.Key]; ok { + return fmt.Errorf("output encoder %q is already registered", normalizedSpec.Key) + } + + if r.constructors == nil { + r.constructors = make(map[string]OutputEncoderConstructor) + } + if r.specs == nil { + r.specs = make(map[string]ModuleSpec) + } + r.constructors[normalizedSpec.Key] = constructor + r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) + return nil +} + +func (r *OutputEncoderRegistry) Build(key string) (contracts.OutputEncoder, error) { + if r == nil { + return nil, fmt.Errorf("output encoder registry must not be nil") + } + + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return nil, fmt.Errorf("output encoder key must not be empty") + } + + constructor, ok := r.constructors[normalizedKey] + if !ok { + return nil, fmt.Errorf("output encoder %q is not registered", normalizedKey) + } + + encoder, err := constructor() + if err != nil { + return nil, fmt.Errorf("build output encoder %q: %w", normalizedKey, err) + } + if encoder == nil { + return nil, fmt.Errorf("output encoder %q constructor returned nil", normalizedKey) + } + if encoder.Key() != normalizedKey { + return nil, fmt.Errorf("output encoder %q returned key %q", normalizedKey, encoder.Key()) + } + + return encoder, nil +} + +func (r *OutputEncoderRegistry) Spec(key string) (ModuleSpec, bool) { + if r == nil { + return ModuleSpec{}, false + } + + spec, ok := r.specs[strings.TrimSpace(key)] + if !ok { + return ModuleSpec{}, false + } + return cloneModuleSpec(spec), true +} + +func (r *OutputEncoderRegistry) RegisteredKeys() []string { + if r == nil { + return nil + } + + return sortedRegistryKeys(r.constructors) +} diff --git a/internal/framework/pipeline/output_registry_test.go b/internal/framework/pipeline/output_registry_test.go new file mode 100644 index 0000000..a2fca67 --- /dev/null +++ b/internal/framework/pipeline/output_registry_test.go @@ -0,0 +1,58 @@ +package pipeline + +import ( + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestOutputEncoderRegistryBehavior(t *testing.T) { + runRegistryBehaviorTests(t, registryBehaviorCase[contracts.OutputEncoder]{ + name: "OutputEncoderRegistry", + key: "generic-output", + stage: StageOutput, + wrongStage: StageExtract, + newRegistry: func() any { + return NewOutputEncoderRegistry() + }, + register: func(registry any, key string, constructor func() (contracts.OutputEncoder, error)) error { + return registry.(*OutputEncoderRegistry).Register(key, constructor) + }, + registerWithSpec: func(registry any, spec ModuleSpec, constructor func() (contracts.OutputEncoder, error)) error { + return registry.(*OutputEncoderRegistry).RegisterWithSpec(spec, constructor) + }, + build: func(registry any, key string) (contracts.OutputEncoder, error) { + return registry.(*OutputEncoderRegistry).Build(key) + }, + spec: func(registry any, key string) (ModuleSpec, bool) { + return registry.(*OutputEncoderRegistry).Spec(key) + }, + registeredKeys: func(registry any) []string { + return registry.(*OutputEncoderRegistry).RegisteredKeys() + }, + nilRegister: func(key string, constructor func() (contracts.OutputEncoder, error)) error { + var registry *OutputEncoderRegistry + return registry.Register(key, constructor) + }, + nilBuild: func(key string) (contracts.OutputEncoder, error) { + var registry *OutputEncoderRegistry + return registry.Build(key) + }, + nilSpec: func(key string) (ModuleSpec, bool) { + var registry *OutputEncoderRegistry + return registry.Spec(key) + }, + nilRegisteredKey: func() []string { + var registry *OutputEncoderRegistry + return registry.RegisteredKeys() + }, + constructor: func(key string) func() (contracts.OutputEncoder, error) { + return func() (contracts.OutputEncoder, error) { + return registryOutputEncoder{key: key}, nil + } + }, + moduleKey: func(module contracts.OutputEncoder) string { + return module.Key() + }, + }) +} diff --git a/internal/framework/pipeline/validator_registry.go b/internal/framework/pipeline/validator_registry.go new file mode 100644 index 0000000..865a417 --- /dev/null +++ b/internal/framework/pipeline/validator_registry.go @@ -0,0 +1,102 @@ +package pipeline + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +type ValidatorConstructor func() (contracts.Validator, error) + +type ValidatorRegistry struct { + constructors map[string]ValidatorConstructor + specs map[string]ModuleSpec +} + +func NewValidatorRegistry() *ValidatorRegistry { + return &ValidatorRegistry{ + constructors: make(map[string]ValidatorConstructor), + specs: make(map[string]ModuleSpec), + } +} + +func (r *ValidatorRegistry) Register(key string, constructor ValidatorConstructor) error { + return r.RegisterWithSpec(defaultModuleSpec(key, StageValidate), constructor) +} + +func (r *ValidatorRegistry) RegisterWithSpec(spec ModuleSpec, constructor ValidatorConstructor) error { + if r == nil { + return fmt.Errorf("validator registry must not be nil") + } + + normalizedSpec := normalizeModuleSpec(spec) + if err := validateModuleSpec("validator", StageValidate, normalizedSpec); err != nil { + return err + } + if constructor == nil { + return fmt.Errorf("validator constructor for %q must not be nil", normalizedSpec.Key) + } + if _, ok := r.constructors[normalizedSpec.Key]; ok { + return fmt.Errorf("validator %q is already registered", normalizedSpec.Key) + } + + if r.constructors == nil { + r.constructors = make(map[string]ValidatorConstructor) + } + if r.specs == nil { + r.specs = make(map[string]ModuleSpec) + } + r.constructors[normalizedSpec.Key] = constructor + r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) + return nil +} + +func (r *ValidatorRegistry) Build(key string) (contracts.Validator, error) { + if r == nil { + return nil, fmt.Errorf("validator registry must not be nil") + } + + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return nil, fmt.Errorf("validator key must not be empty") + } + + constructor, ok := r.constructors[normalizedKey] + if !ok { + return nil, fmt.Errorf("validator %q is not registered", normalizedKey) + } + + validator, err := constructor() + if err != nil { + return nil, fmt.Errorf("build validator %q: %w", normalizedKey, err) + } + if validator == nil { + return nil, fmt.Errorf("validator %q constructor returned nil", normalizedKey) + } + if validator.Name() != normalizedKey { + return nil, fmt.Errorf("validator %q returned name %q", normalizedKey, validator.Name()) + } + + return validator, nil +} + +func (r *ValidatorRegistry) Spec(key string) (ModuleSpec, bool) { + if r == nil { + return ModuleSpec{}, false + } + + spec, ok := r.specs[strings.TrimSpace(key)] + if !ok { + return ModuleSpec{}, false + } + return cloneModuleSpec(spec), true +} + +func (r *ValidatorRegistry) RegisteredKeys() []string { + if r == nil { + return nil + } + + return sortedRegistryKeys(r.constructors) +} diff --git a/internal/framework/pipeline/validator_registry_test.go b/internal/framework/pipeline/validator_registry_test.go new file mode 100644 index 0000000..286d59d --- /dev/null +++ b/internal/framework/pipeline/validator_registry_test.go @@ -0,0 +1,58 @@ +package pipeline + +import ( + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestValidatorRegistryBehavior(t *testing.T) { + runRegistryBehaviorTests(t, registryBehaviorCase[contracts.Validator]{ + name: "ValidatorRegistry", + key: "generic-validator", + stage: StageValidate, + wrongStage: StageExtract, + newRegistry: func() any { + return NewValidatorRegistry() + }, + register: func(registry any, key string, constructor func() (contracts.Validator, error)) error { + return registry.(*ValidatorRegistry).Register(key, constructor) + }, + registerWithSpec: func(registry any, spec ModuleSpec, constructor func() (contracts.Validator, error)) error { + return registry.(*ValidatorRegistry).RegisterWithSpec(spec, constructor) + }, + build: func(registry any, key string) (contracts.Validator, error) { + return registry.(*ValidatorRegistry).Build(key) + }, + spec: func(registry any, key string) (ModuleSpec, bool) { + return registry.(*ValidatorRegistry).Spec(key) + }, + registeredKeys: func(registry any) []string { + return registry.(*ValidatorRegistry).RegisteredKeys() + }, + nilRegister: func(key string, constructor func() (contracts.Validator, error)) error { + var registry *ValidatorRegistry + return registry.Register(key, constructor) + }, + nilBuild: func(key string) (contracts.Validator, error) { + var registry *ValidatorRegistry + return registry.Build(key) + }, + nilSpec: func(key string) (ModuleSpec, bool) { + var registry *ValidatorRegistry + return registry.Spec(key) + }, + nilRegisteredKey: func() []string { + var registry *ValidatorRegistry + return registry.RegisteredKeys() + }, + constructor: func(key string) func() (contracts.Validator, error) { + return func() (contracts.Validator, error) { + return registryValidator{name: key}, nil + } + }, + moduleKey: func(module contracts.Validator) string { + return module.Name() + }, + }) +}