283 lines
9.4 KiB
Go
283 lines
9.4 KiB
Go
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,
|
|
}
|
|
}
|