Add typed artifact codec foundation
This commit is contained in:
282
internal/framework/pipeline/artifact_codec_registry.go
Normal file
282
internal/framework/pipeline/artifact_codec_registry.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
type ArtifactCodecSpec struct {
|
||||
Kind contracts.ArtifactKind
|
||||
Schema contracts.ArtifactSchema
|
||||
SchemaDigest string
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// ArtifactCodecTypeError reports a value that does not have the exact Go type
|
||||
// registered for an artifact kind.
|
||||
type ArtifactCodecTypeError struct {
|
||||
Operation string
|
||||
Kind contracts.ArtifactKind
|
||||
ExpectedType string
|
||||
ActualType string
|
||||
}
|
||||
|
||||
func (e *ArtifactCodecTypeError) Error() string {
|
||||
return fmt.Sprintf("%s artifact %q: expected exact Go type %s, got %s", e.Operation, e.Kind, e.ExpectedType, e.ActualType)
|
||||
}
|
||||
|
||||
// ArtifactCodecCompatibilityError reports serialized metadata that does not
|
||||
// identify the registered representation for an artifact kind.
|
||||
type ArtifactCodecCompatibilityError struct {
|
||||
Kind contracts.ArtifactKind
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *ArtifactCodecCompatibilityError) Error() string {
|
||||
return fmt.Sprintf("decode artifact %q: %s", e.Kind, e.Reason)
|
||||
}
|
||||
|
||||
// ArtifactCodecOperationError preserves an encode or decode failure from the
|
||||
// registered domain codec.
|
||||
type ArtifactCodecOperationError struct {
|
||||
Operation string
|
||||
Kind contracts.ArtifactKind
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ArtifactCodecOperationError) Error() string {
|
||||
return fmt.Sprintf("%s artifact %q: %v", e.Operation, e.Kind, e.Err)
|
||||
}
|
||||
|
||||
func (e *ArtifactCodecOperationError) Unwrap() error { return e.Err }
|
||||
|
||||
type ArtifactCodecRegistry struct {
|
||||
entries map[contracts.ArtifactKind]artifactCodecEntry
|
||||
}
|
||||
|
||||
type artifactCodecEntry struct {
|
||||
spec ArtifactCodecSpec
|
||||
valueType reflect.Type
|
||||
encode func(any) ([]byte, error)
|
||||
decode func([]byte) (any, error)
|
||||
}
|
||||
|
||||
func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
|
||||
return &ArtifactCodecRegistry{entries: make(map[contracts.ArtifactKind]artifactCodecEntry)}
|
||||
}
|
||||
|
||||
// RegisterArtifactCodec registers one codec for T. The concrete type is kept
|
||||
// private and checked at every erased encode boundary.
|
||||
func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) error {
|
||||
if registry == nil {
|
||||
return fmt.Errorf("artifact codec registry must not be nil")
|
||||
}
|
||||
if nilInterface(codec) {
|
||||
return fmt.Errorf("artifact codec must not be nil")
|
||||
}
|
||||
|
||||
spec, err := artifactCodecSpec(codec.Kind(), codec.Schema(), codec.MediaType())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := registry.entries[spec.Kind]; ok {
|
||||
return fmt.Errorf("artifact codec %q is already registered", spec.Kind)
|
||||
}
|
||||
|
||||
valueType := reflect.TypeFor[T]()
|
||||
entry := artifactCodecEntry{
|
||||
spec: cloneArtifactCodecSpec(spec),
|
||||
valueType: valueType,
|
||||
encode: func(value any) ([]byte, error) {
|
||||
actualType := reflect.TypeOf(value)
|
||||
if actualType != valueType {
|
||||
return nil, newArtifactCodecTypeError("encode", spec.Kind, valueType, actualType)
|
||||
}
|
||||
typed, ok := value.(T)
|
||||
if !ok {
|
||||
return nil, newArtifactCodecTypeError("encode", spec.Kind, valueType, actualType)
|
||||
}
|
||||
encoded, err := codec.Encode(typed)
|
||||
if err != nil {
|
||||
return nil, &ArtifactCodecOperationError{Operation: "encode", Kind: spec.Kind, Err: err}
|
||||
}
|
||||
return append([]byte(nil), encoded...), nil
|
||||
},
|
||||
decode: func(content []byte) (any, error) {
|
||||
decoded, err := codec.Decode(append([]byte(nil), content...))
|
||||
if err != nil {
|
||||
return nil, &ArtifactCodecOperationError{Operation: "decode", Kind: spec.Kind, Err: err}
|
||||
}
|
||||
return decoded, nil
|
||||
},
|
||||
}
|
||||
if registry.entries == nil {
|
||||
registry.entries = make(map[contracts.ArtifactKind]artifactCodecEntry)
|
||||
}
|
||||
registry.entries[spec.Kind] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ArtifactCodecRegistry) Spec(kind contracts.ArtifactKind) (ArtifactCodecSpec, bool) {
|
||||
if r == nil {
|
||||
return ArtifactCodecSpec{}, false
|
||||
}
|
||||
entry, ok := r.entries[normalizeArtifactKind(kind)]
|
||||
if !ok {
|
||||
return ArtifactCodecSpec{}, false
|
||||
}
|
||||
return cloneArtifactCodecSpec(entry.spec), true
|
||||
}
|
||||
|
||||
func (r *ArtifactCodecRegistry) RegisteredKinds() []contracts.ArtifactKind {
|
||||
if r == nil || len(r.entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
kinds := make([]contracts.ArtifactKind, 0, len(r.entries))
|
||||
for kind := range r.entries {
|
||||
kinds = append(kinds, kind)
|
||||
}
|
||||
sort.Slice(kinds, func(i, j int) bool { return kinds[i] < kinds[j] })
|
||||
return kinds
|
||||
}
|
||||
|
||||
// Encode serializes an erased framework value after proving its exact
|
||||
// registered Go type.
|
||||
func (r *ArtifactCodecRegistry) Encode(kind contracts.ArtifactKind, value any) (contracts.SerializedArtifact, error) {
|
||||
entry, normalizedKind, err := r.entry(kind)
|
||||
if err != nil {
|
||||
return contracts.SerializedArtifact{}, err
|
||||
}
|
||||
content, err := entry.encode(value)
|
||||
if err != nil {
|
||||
return contracts.SerializedArtifact{}, err
|
||||
}
|
||||
return contracts.SerializedArtifact{
|
||||
Kind: normalizedKind,
|
||||
Schema: contracts.CloneArtifactSchema(entry.spec.Schema),
|
||||
MediaType: entry.spec.MediaType,
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Decode verifies serialized identity before invoking the registered codec.
|
||||
func (r *ArtifactCodecRegistry) Decode(artifact contracts.SerializedArtifact) (any, error) {
|
||||
entry, normalizedKind, err := r.entry(artifact.Kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if artifact.Schema.ID != entry.spec.Schema.ID || artifact.Schema.Name != entry.spec.Schema.Name || artifact.Schema.Version != entry.spec.Schema.Version {
|
||||
return nil, &ArtifactCodecCompatibilityError{Kind: normalizedKind, Reason: "schema identity does not match registered codec"}
|
||||
}
|
||||
if contracts.DigestArtifactSchema(artifact.Schema) != entry.spec.SchemaDigest {
|
||||
return nil, &ArtifactCodecCompatibilityError{Kind: normalizedKind, Reason: "schema digest does not match registered codec"}
|
||||
}
|
||||
if strings.TrimSpace(artifact.MediaType) != entry.spec.MediaType {
|
||||
return nil, &ArtifactCodecCompatibilityError{Kind: normalizedKind, Reason: "media type does not match registered codec"}
|
||||
}
|
||||
return entry.decode(artifact.Content)
|
||||
}
|
||||
|
||||
func (r *ArtifactCodecRegistry) entry(kind contracts.ArtifactKind) (artifactCodecEntry, contracts.ArtifactKind, error) {
|
||||
if r == nil {
|
||||
return artifactCodecEntry{}, "", fmt.Errorf("artifact codec registry must not be nil")
|
||||
}
|
||||
normalizedKind := normalizeArtifactKind(kind)
|
||||
if normalizedKind == "" {
|
||||
return artifactCodecEntry{}, "", fmt.Errorf("artifact kind must not be empty")
|
||||
}
|
||||
entry, ok := r.entries[normalizedKind]
|
||||
if !ok {
|
||||
return artifactCodecEntry{}, normalizedKind, fmt.Errorf("artifact codec %q is not registered", normalizedKind)
|
||||
}
|
||||
return entry, normalizedKind, nil
|
||||
}
|
||||
|
||||
func artifactCodecSpec(kind contracts.ArtifactKind, schema contracts.ArtifactSchema, mediaType string) (ArtifactCodecSpec, error) {
|
||||
kind = normalizeArtifactKind(kind)
|
||||
schema.ID = strings.TrimSpace(schema.ID)
|
||||
schema.Name = strings.TrimSpace(schema.Name)
|
||||
schema.Version = strings.TrimSpace(schema.Version)
|
||||
mediaType = strings.TrimSpace(mediaType)
|
||||
switch {
|
||||
case kind == "":
|
||||
return ArtifactCodecSpec{}, fmt.Errorf("artifact codec kind must not be empty")
|
||||
case schema.ID == "":
|
||||
return ArtifactCodecSpec{}, fmt.Errorf("artifact codec %q schema id must not be empty", kind)
|
||||
case schema.Name == "":
|
||||
return ArtifactCodecSpec{}, fmt.Errorf("artifact codec %q schema name must not be empty", kind)
|
||||
case schema.Version == "":
|
||||
return ArtifactCodecSpec{}, fmt.Errorf("artifact codec %q schema version must not be empty", kind)
|
||||
case mediaType == "":
|
||||
return ArtifactCodecSpec{}, fmt.Errorf("artifact codec %q media type must not be empty", kind)
|
||||
case len(bytes.TrimSpace(schema.JSONSchema)) == 0:
|
||||
return ArtifactCodecSpec{}, fmt.Errorf("artifact codec %q JSON Schema must not be empty", kind)
|
||||
case !json.Valid(schema.JSONSchema):
|
||||
return ArtifactCodecSpec{}, fmt.Errorf("artifact codec %q JSON Schema must be valid JSON", kind)
|
||||
}
|
||||
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(schema.JSONSchema))
|
||||
if err != nil {
|
||||
return ArtifactCodecSpec{}, fmt.Errorf("artifact codec %q JSON Schema is invalid: %w", kind, err)
|
||||
}
|
||||
compiler := jsonschema.NewCompiler()
|
||||
if err := compiler.AddResource("artifact-schema.json", schemaDocument); err != nil {
|
||||
return ArtifactCodecSpec{}, fmt.Errorf("artifact codec %q JSON Schema is invalid: %w", kind, err)
|
||||
}
|
||||
if _, err := compiler.Compile("artifact-schema.json"); err != nil {
|
||||
return ArtifactCodecSpec{}, fmt.Errorf("artifact codec %q JSON Schema is invalid: %w", kind, err)
|
||||
}
|
||||
schema = contracts.CloneArtifactSchema(schema)
|
||||
return ArtifactCodecSpec{
|
||||
Kind: kind,
|
||||
Schema: schema,
|
||||
SchemaDigest: contracts.DigestArtifactSchema(schema),
|
||||
MediaType: mediaType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeArtifactKind(kind contracts.ArtifactKind) contracts.ArtifactKind {
|
||||
return contracts.ArtifactKind(strings.TrimSpace(string(kind)))
|
||||
}
|
||||
|
||||
func cloneArtifactCodecSpec(spec ArtifactCodecSpec) ArtifactCodecSpec {
|
||||
spec.Schema = contracts.CloneArtifactSchema(spec.Schema)
|
||||
return spec
|
||||
}
|
||||
|
||||
func nilInterface(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
reflected := reflect.ValueOf(value)
|
||||
switch reflected.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return reflected.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func newArtifactCodecTypeError(operation string, kind contracts.ArtifactKind, expected reflect.Type, actual reflect.Type) error {
|
||||
expectedName := "<nil>"
|
||||
if expected != nil {
|
||||
expectedName = expected.String()
|
||||
}
|
||||
actualName := "<nil>"
|
||||
if actual != nil {
|
||||
actualName = actual.String()
|
||||
}
|
||||
return &ArtifactCodecTypeError{
|
||||
Operation: operation,
|
||||
Kind: kind,
|
||||
ExpectedType: expectedName,
|
||||
ActualType: actualName,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user