Add reference contracts to extractor metadata
This commit is contained in:
@@ -117,6 +117,8 @@ func (defaultExtractor) ArtifactType() string { return "record" }
|
||||
|
||||
func (defaultExtractor) SchemaVersion() string { return "v1" }
|
||||
|
||||
func (defaultExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
|
||||
func (defaultExtractor) Validators() []contracts.Validator { return nil }
|
||||
|
||||
func (defaultExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) {
|
||||
|
||||
@@ -49,6 +49,20 @@ func TestExtractorRegistryRegisterWithSpecStoresMetadata(t *testing.T) {
|
||||
Stage: StageExtract,
|
||||
Provides: []string{" generic-artifact ", "source-citations", "generic-artifact", ""},
|
||||
Requires: []string{" source-document ", "source-document", ""},
|
||||
ReferenceSlots: []contracts.ReferenceSlot{
|
||||
{
|
||||
Name: " glossary ",
|
||||
Description: " Supporting terms ",
|
||||
AcceptedMediaTypes: []string{" text/plain ", "text/markdown", "text/plain", ""},
|
||||
MaxBytes: 1024,
|
||||
},
|
||||
{
|
||||
Name: " roster ",
|
||||
Description: " Characters ",
|
||||
Required: true,
|
||||
Multiple: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := registry.RegisterWithSpec(spec, fakeExtractorConstructor("generic-extractor")); err != nil {
|
||||
@@ -64,12 +78,28 @@ func TestExtractorRegistryRegisterWithSpecStoresMetadata(t *testing.T) {
|
||||
Stage: StageExtract,
|
||||
Provides: []string{"generic-artifact", "source-citations"},
|
||||
Requires: []string{"source-document"},
|
||||
ReferenceSlots: []contracts.ReferenceSlot{
|
||||
{
|
||||
Name: "glossary",
|
||||
Description: "Supporting terms",
|
||||
AcceptedMediaTypes: []string{"text/markdown", "text/plain"},
|
||||
MaxBytes: 1024,
|
||||
},
|
||||
{
|
||||
Name: "roster",
|
||||
Description: "Characters",
|
||||
Required: true,
|
||||
Multiple: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Spec() = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got.Provides[0] = "changed"
|
||||
got.ReferenceSlots[0].Name = "changed"
|
||||
got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
|
||||
again, ok := registry.Spec("generic-extractor")
|
||||
if !ok {
|
||||
t.Fatal("Spec() after caller mutation ok = false, want true")
|
||||
@@ -109,6 +139,50 @@ func TestExtractorRegistryRegisterWithSpecRejectsWrongStage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractorRegistryRejectsInvalidReferenceSlots(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slots []contracts.ReferenceSlot
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty name",
|
||||
slots: []contracts.ReferenceSlot{{Name: " "}},
|
||||
want: "name",
|
||||
},
|
||||
{
|
||||
name: "duplicate name after trim",
|
||||
slots: []contracts.ReferenceSlot{
|
||||
{Name: "roster"},
|
||||
{Name: " roster "},
|
||||
},
|
||||
want: "duplicated",
|
||||
},
|
||||
{
|
||||
name: "negative max bytes",
|
||||
slots: []contracts.ReferenceSlot{{Name: "roster", MaxBytes: -1}},
|
||||
want: "max_bytes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
registry := NewExtractorRegistry()
|
||||
err := registry.RegisterWithSpec(ModuleSpec{
|
||||
Key: "generic-extractor",
|
||||
Stage: StageExtract,
|
||||
ReferenceSlots: test.slots,
|
||||
}, fakeExtractorConstructor("generic-extractor"))
|
||||
if err == nil {
|
||||
t.Fatal("RegisterWithSpec() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("RegisterWithSpec() error = %q, want %q", err.Error(), test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractorRegistrySpecRejectsUnknownKey(t *testing.T) {
|
||||
registry := NewExtractorRegistry()
|
||||
|
||||
@@ -301,6 +375,10 @@ func (extractor registryFakeExtractor) SchemaVersion() string {
|
||||
return "v1"
|
||||
}
|
||||
|
||||
func (extractor registryFakeExtractor) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (extractor registryFakeExtractor) Validators() []contracts.Validator {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
type ModuleStage string
|
||||
@@ -19,10 +21,11 @@ const (
|
||||
)
|
||||
|
||||
type ModuleSpec struct {
|
||||
Key string
|
||||
Stage ModuleStage
|
||||
Provides []string
|
||||
Requires []string
|
||||
Key string
|
||||
Stage ModuleStage
|
||||
Provides []string
|
||||
Requires []string
|
||||
ReferenceSlots []contracts.ReferenceSlot
|
||||
}
|
||||
|
||||
func defaultModuleSpec(key string, stage ModuleStage) ModuleSpec {
|
||||
@@ -34,10 +37,11 @@ func defaultModuleSpec(key string, stage ModuleStage) ModuleSpec {
|
||||
|
||||
func normalizeModuleSpec(spec ModuleSpec) ModuleSpec {
|
||||
return ModuleSpec{
|
||||
Key: strings.TrimSpace(spec.Key),
|
||||
Stage: spec.Stage,
|
||||
Provides: normalizeCapabilities(spec.Provides),
|
||||
Requires: normalizeCapabilities(spec.Requires),
|
||||
Key: strings.TrimSpace(spec.Key),
|
||||
Stage: spec.Stage,
|
||||
Provides: normalizeCapabilities(spec.Provides),
|
||||
Requires: normalizeCapabilities(spec.Requires),
|
||||
ReferenceSlots: normalizeReferenceSlots(spec.ReferenceSlots),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +72,11 @@ func normalizeCapabilities(values []string) []string {
|
||||
|
||||
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...),
|
||||
Key: spec.Key,
|
||||
Stage: spec.Stage,
|
||||
Provides: append([]string(nil), spec.Provides...),
|
||||
Requires: append([]string(nil), spec.Requires...),
|
||||
ReferenceSlots: cloneReferenceSlots(spec.ReferenceSlots),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +87,12 @@ func validateModuleSpec(kind string, expectedStage ModuleStage, spec ModuleSpec)
|
||||
if spec.Stage != expectedStage {
|
||||
return fmt.Errorf("%s %q must use %q stage, got %q", kind, spec.Key, expectedStage, spec.Stage)
|
||||
}
|
||||
if spec.Stage != StageExtract && len(spec.ReferenceSlots) > 0 {
|
||||
return fmt.Errorf("%s %q must not declare reference slots", kind, spec.Key)
|
||||
}
|
||||
if err := validateReferenceSlots(spec.ReferenceSlots); err != nil {
|
||||
return fmt.Errorf("%s %q reference slots: %w", kind, spec.Key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -97,3 +108,74 @@ func sortedRegistryKeys[C any](constructors map[string]C) []string {
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func normalizeReferenceSlots(slots []contracts.ReferenceSlot) []contracts.ReferenceSlot {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalized := make([]contracts.ReferenceSlot, 0, len(slots))
|
||||
for _, slot := range slots {
|
||||
slot.Name = strings.TrimSpace(slot.Name)
|
||||
slot.Description = strings.TrimSpace(slot.Description)
|
||||
slot.AcceptedMediaTypes = normalizeStringSet(slot.AcceptedMediaTypes)
|
||||
normalized = append(normalized, slot)
|
||||
}
|
||||
sort.SliceStable(normalized, func(i, j int) bool {
|
||||
return normalized[i].Name < normalized[j].Name
|
||||
})
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeStringSet(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
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(seen))
|
||||
for value := range seen {
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func validateReferenceSlots(slots []contracts.ReferenceSlot) error {
|
||||
seen := make(map[string]struct{}, len(slots))
|
||||
for i, slot := range slots {
|
||||
if slot.Name == "" {
|
||||
return fmt.Errorf("slot[%d].name must not be empty", i)
|
||||
}
|
||||
if _, ok := seen[slot.Name]; ok {
|
||||
return fmt.Errorf("slot name %q is duplicated", slot.Name)
|
||||
}
|
||||
seen[slot.Name] = struct{}{}
|
||||
if slot.MaxBytes < 0 {
|
||||
return fmt.Errorf("slot %q max_bytes must not be negative", slot.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneReferenceSlots(slots []contracts.ReferenceSlot) []contracts.ReferenceSlot {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]contracts.ReferenceSlot, 0, len(slots))
|
||||
for _, slot := range slots {
|
||||
slot.AcceptedMediaTypes = append([]string(nil), slot.AcceptedMediaTypes...)
|
||||
out = append(out, slot)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
25
internal/framework/pipeline/module_test.go
Normal file
25
internal/framework/pipeline/module_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestValidateModuleSpecRejectsReferenceSlotsForNonExtractors(t *testing.T) {
|
||||
err := validateModuleSpec("chunker", StageChunk, ModuleSpec{
|
||||
Key: "generic",
|
||||
Stage: StageChunk,
|
||||
ReferenceSlots: []contracts.ReferenceSlot{
|
||||
{Name: "roster"},
|
||||
},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("validateModuleSpec() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reference slots") {
|
||||
t.Fatalf("validateModuleSpec() error = %q, want reference slots context", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,10 @@ func (extractor integrationExtractor) SchemaVersion() string {
|
||||
return "v1"
|
||||
}
|
||||
|
||||
func (extractor integrationExtractor) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (extractor integrationExtractor) Validators() []contracts.Validator {
|
||||
return extractor.validators
|
||||
}
|
||||
|
||||
@@ -1269,6 +1269,10 @@ func (extractor *runnerExtractor) SchemaVersion() string {
|
||||
return extractor.schemaVersion
|
||||
}
|
||||
|
||||
func (extractor *runnerExtractor) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (extractor *runnerExtractor) ManifestMetadata() map[string]any {
|
||||
return extractor.manifestMetadata
|
||||
}
|
||||
|
||||
@@ -252,6 +252,10 @@ func (extractor walkingSkeletonExtractor) SchemaVersion() string {
|
||||
return "v1"
|
||||
}
|
||||
|
||||
func (extractor walkingSkeletonExtractor) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (extractor walkingSkeletonExtractor) Validators() []contracts.Validator {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user