Ctrl K
ComunidadGuías…

internal/webhooks/schema_compat.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package webhooks

import (
	"fmt"
	"reflect"
	"slices"
	"sort"
	"strings"
)

// SchemaCompatibility is the result of comparing two versions of an event
// type's JSON Schema from a subscriber's point of view.
type SchemaCompatibility struct {
	// Breaking is true when the change could break a subscription's payload
	// transformation.
	Breaking bool
	// Reasons lists each breaking change with the path it affects, e.g.
	// "order.total: removed required property".
	Reasons []string
}

// Result is "breaking" or "compatible".
func (c SchemaCompatibility) Result() string {
	if c.Breaking {
		return "breaking"
	}
	return "compatible"
}

// compositionKeywords are JSON Schema keywords whose effect on the payload
// shape the classifier cannot reason about. A change under a schema that uses
// them cannot be proven safe, so it counts as breaking.
var compositionKeywords = []string{"oneOf", "anyOf", "allOf", "not", "$ref", "patternProperties", "if", "then", "else", "dependentSchemas"}

// ClassifySchemaChange decides whether moving an event type from oldSchema to
// newSchema could break a subscription's payload transformation. The rule is
// strict: anything the classifier cannot prove safe is breaking.
//
// Compatible:
//   - adding a property, optional or required (a subscriber only reads what
//     it already knew about)
//   - removing an optional property (the schema never promised it)
//   - narrowing a type (number to integer, or adding a type where there was none)
//   - changing value constraints: enum, minimum, maxLength, pattern, format, ...
//
// Breaking:
//   - removing a required property, or making it optional
//   - a type change that allows values of a type the old schema did not
//     (string to integer, object to array, integer to number)
//   - removing the schema entirely
//   - any change under oneOf, anyOf, allOf, not, $ref, patternProperties,
//     if/then/else or dependentSchemas
//
// Adding a first schema to a type that had none is not a change to a
// contract, so it is never breaking.
func ClassifySchemaChange(oldSchema, newSchema map[string]any) SchemaCompatibility {
	var c SchemaCompatibility
	switch {
	case len(oldSchema) == 0:
		return c
	case len(newSchema) == 0:
		c.add("", "schema removed; every required property is gone")
		return c
	}
	c.compare("", oldSchema, newSchema)
	sort.Strings(c.Reasons)
	return c
}

func (c *SchemaCompatibility) add(path, reason string) {
	c.Breaking = true
	if path == "" {
		path = "(root)"
	}
	c.Reasons = append(c.Reasons, path+": "+reason)
}

// compare checks one schema node and recurses into properties and items.
func (c *SchemaCompatibility) compare(path string, oldNode, newNode map[string]any) {
	if reflect.DeepEqual(oldNode, newNode) {
		return
	}
	if kw := firstComposition(oldNode, newNode); kw != "" {
		c.add(path, fmt.Sprintf("changed under %s, which cannot be checked for compatibility", kw))
		return
	}

	if reason := typeChange(oldNode, newNode); reason != "" {
		c.add(path, reason)
	}

	oldProps, _ := oldNode["properties"].(map[string]any)
	newProps, _ := newNode["properties"].(map[string]any)
	oldReq, newReq := requiredSet(oldNode), requiredSet(newNode)
	for _, name := range sortedKeys(oldProps) {
		propPath := joinPath(path, name)
		oldProp, _ := oldProps[name].(map[string]any)
		newPropRaw, stillThere := newProps[name]
		wasRequired, isRequired := oldReq[name], newReq[name]

		switch {
		case !stillThere && !wasRequired:
			// Removed optional property: the schema never promised it.
			continue
		case !stillThere && !isRequired:
			c.add(propPath, "removed required property")
			continue
		case !stillThere:
			c.add(propPath, "property definition removed, so its shape is no longer described")
			continue
		case wasRequired && !isRequired:
			c.add(propPath, "no longer required, so it may be absent")
		}
		newProp, _ := newPropRaw.(map[string]any)
		c.compare(propPath, oldProp, newProp)
	}

	if oldItems, ok := oldNode["items"].(map[string]any); ok {
		if newItems, ok := newNode["items"].(map[string]any); ok {
			c.compare(path+"[]", oldItems, newItems)
		} else {
			c.add(path+"[]", "item schema removed, so items may have any shape")
		}
	}
}

// typeChange reports a type change that lets through values the old schema
// did not, or "" if the new types are a subset of the old ones.
func typeChange(oldNode, newNode map[string]any) string {
	oldTypes, newTypes := typesOf(oldNode), typesOf(newNode)
	if len(oldTypes) == 0 {
		// Old schema allowed any type: whatever the new one says is narrower.
		return ""
	}
	if len(newTypes) == 0 {
		return fmt.Sprintf("type %s removed, so any type is now allowed", strings.Join(oldTypes, "|"))
	}
	var widened []string
	for _, t := range newTypes {
		if !typeAllows(oldTypes, t) {
			widened = append(widened, t)
		}
	}
	if len(widened) == 0 {
		return ""
	}
	return fmt.Sprintf("type changed from %s to %s", strings.Join(oldTypes, "|"), strings.Join(newTypes, "|"))
}

// typeAllows reports whether a node typed as `types` accepts values of type t.
// An integer is a number, so "number" accepts "integer" but not the reverse.
func typeAllows(types []string, t string) bool {
	if len(types) == 0 {
		return true
	}
	if slices.Contains(types, t) {
		return true
	}
	return t == "integer" && slices.Contains(types, "number")
}

func typesOf(node map[string]any) []string {
	switch t := node["type"].(type) {
	case string:
		return []string{t}
	case []any:
		out := make([]string, 0, len(t))
		for _, v := range t {
			if s, ok := v.(string); ok {
				out = append(out, s)
			}
		}
		sort.Strings(out)
		return out
	}
	return nil
}

func requiredSet(node map[string]any) map[string]bool {
	set := map[string]bool{}
	switch list := node["required"].(type) {
	case []any:
		for _, v := range list {
			if s, ok := v.(string); ok {
				set[s] = true
			}
		}
	case []string:
		for _, s := range list {
			set[s] = true
		}
	}
	return set
}

// firstComposition returns the first composition keyword present in either
// node, or "".
func firstComposition(a, b map[string]any) string {
	for _, kw := range compositionKeywords {
		if _, ok := a[kw]; ok {
			return kw
		}
		if _, ok := b[kw]; ok {
			return kw
		}
	}
	return ""
}

func sortedKeys(m map[string]any) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

func joinPath(path, name string) string {
	if path == "" {
		return name
	}
	return path + "." + name
}