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 <noreply@anthropic.com>
This commit is contained in:
parent
b0c1c1eea5
commit
5690fa63ae
4 changed files with 269 additions and 3 deletions
83
pkg/sdk/diff.go
Normal file
83
pkg/sdk/diff.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
101
pkg/sdk/diff_test.go
Normal file
101
pkg/sdk/diff_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,29 @@ module github.com/host-uk/core/pkg/sdk
|
||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/getkin/kin-openapi v0.128.0
|
github.com/getkin/kin-openapi v0.133.0
|
||||||
github.com/tufin/oasdiff v1.10.25
|
github.com/oasdiff/oasdiff v1.11.8
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
)
|
||||||
|
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
60
pkg/sdk/go.sum
Normal file
60
pkg/sdk/go.sum
Normal file
|
|
@ -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=
|
||||||
Loading…
Add table
Reference in a new issue