From 5690fa63ae26096d2d7507bb08628dfe451d25cf Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 01:19:44 +0000 Subject: [PATCH] feat(sdk): add breaking change detection with oasdiff Compares OpenAPI specs to detect breaking changes: - Removed endpoints - Changed required parameters - Modified response schemas Returns CI-friendly exit codes (0=ok, 1=breaking, 2=error). Co-Authored-By: Claude Opus 4.5 --- pkg/sdk/diff.go | 83 +++++++++++++++++++++++++++++++++++ pkg/sdk/diff_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++ pkg/sdk/go.mod | 28 ++++++++++-- pkg/sdk/go.sum | 60 +++++++++++++++++++++++++ 4 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 pkg/sdk/diff.go create mode 100644 pkg/sdk/diff_test.go create mode 100644 pkg/sdk/go.sum diff --git a/pkg/sdk/diff.go b/pkg/sdk/diff.go new file mode 100644 index 00000000..ebd4f6c1 --- /dev/null +++ b/pkg/sdk/diff.go @@ -0,0 +1,83 @@ +package sdk + +import ( + "fmt" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/oasdiff/oasdiff/checker" + "github.com/oasdiff/oasdiff/diff" + "github.com/oasdiff/oasdiff/load" +) + +// DiffResult holds the result of comparing two OpenAPI specs. +type DiffResult struct { + // Breaking is true if breaking changes were detected. + Breaking bool + // Changes is the list of breaking changes. + Changes []string + // Summary is a human-readable summary. + Summary string +} + +// Diff compares two OpenAPI specs and detects breaking changes. +func Diff(basePath, revisionPath string) (*DiffResult, error) { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + + // Load specs + baseSpec, err := load.NewSpecInfo(loader, load.NewSource(basePath)) + if err != nil { + return nil, fmt.Errorf("sdk.Diff: failed to load base spec: %w", err) + } + + revSpec, err := load.NewSpecInfo(loader, load.NewSource(revisionPath)) + if err != nil { + return nil, fmt.Errorf("sdk.Diff: failed to load revision spec: %w", err) + } + + // Compute diff with operations sources map for better error reporting + diffResult, operationsSources, err := diff.GetWithOperationsSourcesMap(diff.NewConfig(), baseSpec, revSpec) + if err != nil { + return nil, fmt.Errorf("sdk.Diff: failed to compute diff: %w", err) + } + + // Check for breaking changes + config := checker.NewConfig(checker.GetAllChecks()) + breaks := checker.CheckBackwardCompatibilityUntilLevel( + config, + diffResult, + operationsSources, + checker.ERR, // Only errors (breaking changes) + ) + + // Build result + result := &DiffResult{ + Breaking: len(breaks) > 0, + Changes: make([]string, 0, len(breaks)), + } + + localizer := checker.NewDefaultLocalizer() + for _, b := range breaks { + result.Changes = append(result.Changes, b.GetUncolorizedText(localizer)) + } + + if result.Breaking { + result.Summary = fmt.Sprintf("%d breaking change(s) detected", len(breaks)) + } else { + result.Summary = "No breaking changes" + } + + return result, nil +} + +// DiffExitCode returns the exit code for CI integration. +// 0 = no breaking changes, 1 = breaking changes, 2 = error +func DiffExitCode(result *DiffResult, err error) int { + if err != nil { + return 2 + } + if result.Breaking { + return 1 + } + return 0 +} diff --git a/pkg/sdk/diff_test.go b/pkg/sdk/diff_test.go new file mode 100644 index 00000000..812ab84b --- /dev/null +++ b/pkg/sdk/diff_test.go @@ -0,0 +1,101 @@ +package sdk + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDiff_Good_NoBreaking(t *testing.T) { + tmpDir := t.TempDir() + + baseSpec := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + revSpec := `openapi: "3.0.0" +info: + title: Test API + version: "1.1.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK + /status: + get: + operationId: getStatus + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + os.WriteFile(basePath, []byte(baseSpec), 0644) + os.WriteFile(revPath, []byte(revSpec), 0644) + + result, err := Diff(basePath, revPath) + if err != nil { + t.Fatalf("Diff failed: %v", err) + } + if result.Breaking { + t.Error("expected no breaking changes for adding endpoint") + } +} + +func TestDiff_Good_Breaking(t *testing.T) { + tmpDir := t.TempDir() + + baseSpec := `openapi: "3.0.0" +info: + title: Test API + version: "1.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK + /users: + get: + operationId: getUsers + responses: + "200": + description: OK +` + revSpec := `openapi: "3.0.0" +info: + title: Test API + version: "2.0.0" +paths: + /health: + get: + operationId: getHealth + responses: + "200": + description: OK +` + basePath := filepath.Join(tmpDir, "base.yaml") + revPath := filepath.Join(tmpDir, "rev.yaml") + os.WriteFile(basePath, []byte(baseSpec), 0644) + os.WriteFile(revPath, []byte(revSpec), 0644) + + result, err := Diff(basePath, revPath) + if err != nil { + t.Fatalf("Diff failed: %v", err) + } + if !result.Breaking { + t.Error("expected breaking change for removed endpoint") + } +} diff --git a/pkg/sdk/go.mod b/pkg/sdk/go.mod index 97769dcc..6260b737 100644 --- a/pkg/sdk/go.mod +++ b/pkg/sdk/go.mod @@ -3,7 +3,29 @@ module github.com/host-uk/core/pkg/sdk go 1.25 require ( - github.com/getkin/kin-openapi v0.128.0 - github.com/tufin/oasdiff v1.10.25 - gopkg.in/yaml.v3 v3.0.1 + github.com/getkin/kin-openapi v0.133.0 + github.com/oasdiff/oasdiff v1.11.8 +) + +require ( + cloud.google.com/go v0.123.0 // indirect + github.com/TwiN/go-color v1.4.1 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/wI2L/jsondiff v0.7.0 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/yargevad/filepathx v1.0.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect ) diff --git a/pkg/sdk/go.sum b/pkg/sdk/go.sum new file mode 100644 index 00000000..b9dbd765 --- /dev/null +++ b/pkg/sdk/go.sum @@ -0,0 +1,60 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc= +github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/oasdiff/oasdiff v1.11.8 h1:3LalSR0yYVM5sAYNInlIG4TVckLCJBkgjcnst2GKWVg= +github.com/oasdiff/oasdiff v1.11.8/go.mod h1:YtP/1VnQo8FCdSWGJ11a98HFgLnFvUffH//FTDuEpls= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= +github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= +github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=