From 7be997e2492dcb507654e7e41048e5d1c7db4d38 Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Fri, 7 Aug 2026 20:24:21 +0100 Subject: [PATCH 1/2] feat(foas): add reusable OpenAPI diff package --- tools/foas/breakingchanges/doc.go | 19 ++ tools/foas/changelog/changelog.go | 33 +- tools/foas/changelog/merge.go | 27 +- .../changelog/outputfilter/outputfilter.go | 45 ++- tools/foas/diff/classify.go | 47 +++ tools/foas/diff/compare.go | 310 ++++++++++++++++++ tools/foas/diff/compare_test.go | 236 +++++++++++++ tools/foas/diff/doc.go | 21 ++ tools/foas/diff/rules.go | 46 +++ tools/foas/diff/types.go | 114 +++++++ 10 files changed, 853 insertions(+), 45 deletions(-) create mode 100644 tools/foas/breakingchanges/doc.go create mode 100644 tools/foas/diff/classify.go create mode 100644 tools/foas/diff/compare.go create mode 100644 tools/foas/diff/compare_test.go create mode 100644 tools/foas/diff/doc.go create mode 100644 tools/foas/diff/rules.go create mode 100644 tools/foas/diff/types.go diff --git a/tools/foas/breakingchanges/doc.go b/tools/foas/breakingchanges/doc.go new file mode 100644 index 0000000000..6b7249bfc0 --- /dev/null +++ b/tools/foas/breakingchanges/doc.go @@ -0,0 +1,19 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package breakingchanges validates and converts breaking-change exemptions. +// +// Compatibility comparison is provided by the diff package. This package +// handles exemption input and does not compute a separate OpenAPI diff. +package breakingchanges diff --git a/tools/foas/changelog/changelog.go b/tools/foas/changelog/changelog.go index 7fab6e6b8b..6737079c11 100644 --- a/tools/foas/changelog/changelog.go +++ b/tools/foas/changelog/changelog.go @@ -24,41 +24,19 @@ import ( "github.com/mongodb/openapi/tools/foas/apiversion" "github.com/mongodb/openapi/tools/foas/openapi" - "github.com/oasdiff/oasdiff/checker" - "github.com/oasdiff/oasdiff/diff" "github.com/oasdiff/oasdiff/load" ) const ( - deprecationDaysStable = 365 // min days required between deprecating a stable resource and removing it - deprecationDaysBeta = 365 // min days required between deprecating a beta resource and removing it - stabilityLevelStable = "stable" + stabilityLevelStable = "stable" ) -var breakingChangesAdditionalCheckers = map[string]checker.Level{ - "response-non-success-status-removed": checker.ERR, - "api-operation-id-removed": checker.ERR, - "api-tag-removed": checker.ERR, - "response-property-enum-value-removed": checker.ERR, - "response-mediatype-enum-value-removed": checker.ERR, - "request-body-enum-value-removed": checker.ERR, - "api-schema-removed": checker.ERR, - "response-property-one-of-added": checker.INFO, - "response-body-one-of-added": checker.INFO, - "request-parameter-removed": checker.ERR, - "request-property-removed": checker.ERR, - "response-optional-property-removed": checker.ERR, - "response-optional-write-only-property-removed": checker.ERR, -} - type Changelog struct { BaseMetadata *Metadata RevisionMetadata *Metadata Base *load.SpecInfo // the base spec to compare against the revision Revision *load.SpecInfo // the new spec to compare against the base BaseChangelog []*Entry - Config *checker.Config - OasDiff *openapi.OasDiff ExemptionFilePath string RunDate string } @@ -371,11 +349,6 @@ func newChangelog(baseMetadata, revisionMetadata *Metadata, exceptionFilePath st return nil, err } - changelogConfig := checker.NewConfig( - checker.GetAllChecks(), - checker.WithSeverityLevels(breakingChangesAdditionalCheckers), - checker.WithDeprecation(deprecationDaysBeta, deprecationDaysStable)) - return &Changelog{ BaseChangelog: baseChangelog, RunDate: revisionMetadata.RunDate, @@ -383,11 +356,7 @@ func newChangelog(baseMetadata, revisionMetadata *Metadata, exceptionFilePath st Revision: revisionSpec, BaseMetadata: baseMetadata, RevisionMetadata: revisionMetadata, - Config: changelogConfig, ExemptionFilePath: exceptionFilePath, - OasDiff: openapi.NewOasDiffWithSpecInfo(baseSpec, revisionSpec, &diff.Config{ - IncludePathParams: true, - }), }, nil } diff --git a/tools/foas/changelog/merge.go b/tools/foas/changelog/merge.go index 63a0ffcbe1..6db251fe9e 100644 --- a/tools/foas/changelog/merge.go +++ b/tools/foas/changelog/merge.go @@ -15,13 +15,14 @@ package changelog import ( + "context" "encoding/json" "fmt" "log" "sort" "github.com/mongodb/openapi/tools/foas/changelog/outputfilter" - "github.com/oasdiff/oasdiff/checker" + foasdiff "github.com/mongodb/openapi/tools/foas/diff" ) const ( @@ -204,19 +205,25 @@ func (m *Changelog) newPathsFromDeprecatedChanges( } func (m *Changelog) newOasDiffEntries() ([]*outputfilter.OasDiffEntry, error) { - diffResult, err := m.OasDiff.GetFlattenedDiff(m.Base, m.Revision) + report, err := foasdiff.Compare( + context.Background(), + foasdiff.Document{Spec: m.Base.Spec, Source: m.Base.Url}, + foasdiff.Document{Spec: m.Revision.Spec, Source: m.Revision.Url}, + ) if err != nil { return nil, err } - changes := checker.CheckBackwardCompatibilityUntilLevel( - m.Config, - diffResult.Report, - diffResult.SourceMap, - checker.INFO) + checkerChanges := make([]foasdiff.Change, 0, len(report.Changes)) + for index := range report.Changes { + change := report.Changes[index] + if change.Origin == foasdiff.OriginChecker { + checkerChanges = append(checkerChanges, change) + } + } - log.Printf("Found '%d' oasdiff changes between %s and %s", len(changes), m.Base.Url, m.Revision.Url) - return outputfilter.NewChangelogEntries(changes, m.ExemptionFilePath) + log.Printf("Found '%d' diff changes between %s and %s", len(checkerChanges), m.Base.Url, m.Revision.Url) + return outputfilter.NewChangelogEntriesFromDiff(checkerChanges, m.ExemptionFilePath) } // sortChangelog sorts changelog by date DESC, path + httpMethod ASC, version DESC. @@ -268,7 +275,7 @@ func newMergedChanges(changes []*outputfilter.OasDiffEntry, versionChange := &Change{ Description: change.Text, Code: change.ID, - BackwardCompatible: change.LevelWithDefault() < int(checker.ERR), + BackwardCompatible: !change.IsBreaking(), HideFromChangelog: change.HideFromChangelog, DeprecatedVersion: change.DeprecatedVersion, SunsetDate: change.SunsetDate, diff --git a/tools/foas/changelog/outputfilter/outputfilter.go b/tools/foas/changelog/outputfilter/outputfilter.go index 71ebb699a1..3238b21f21 100644 --- a/tools/foas/changelog/outputfilter/outputfilter.go +++ b/tools/foas/changelog/outputfilter/outputfilter.go @@ -16,6 +16,7 @@ package outputfilter import ( "encoding/json" + foasdiff "github.com/mongodb/openapi/tools/foas/diff" "github.com/oasdiff/oasdiff/checker" "github.com/oasdiff/oasdiff/formatters" "github.com/spf13/afero" @@ -44,9 +45,16 @@ func (o *OasDiffEntry) LevelWithDefault() int { if o.Level != 0 { return o.Level } - return int(checker.INFO) + return severityLevel(foasdiff.SeverityInfo) } +func (o *OasDiffEntry) IsBreaking() bool { + return o.LevelWithDefault() >= severityLevel(foasdiff.SeverityError) +} + +// NewChangelogEntries converts raw oasdiff checker changes into changelog +// entries. New comparison callers should use diff.Compare and +// NewChangelogEntriesFromDiff. func NewChangelogEntries(checkers checker.Changes, exemptionsFilePath string) ([]*OasDiffEntry, error) { formatter, err := formatters.Lookup("json", formatters.FormatterOpts{ Language: lan, @@ -61,14 +69,45 @@ func NewChangelogEntries(checkers checker.Changes, exemptionsFilePath string) ([ } var entries []*OasDiffEntry - err = json.Unmarshal(bytes, &entries) - if err != nil { + if err := json.Unmarshal(bytes, &entries); err != nil { return nil, err } + return transformEntries(entries, exemptionsFilePath) +} + +// NewChangelogEntriesFromDiff converts a transport-neutral FOAS diff report +// into changelog entries. +func NewChangelogEntriesFromDiff(changes []foasdiff.Change, exemptionsFilePath string) ([]*OasDiffEntry, error) { + entries := make([]*OasDiffEntry, 0, len(changes)) + for index := range changes { + change := &changes[index] + entries = append(entries, &OasDiffEntry{ + ID: change.ID, + Text: change.Text, + Level: severityLevel(change.Severity), + Operation: change.Operation, + OperationID: change.OperationID, + Path: change.Path, + Source: change.Source, + Section: change.Section, + }) + } return transformEntries(entries, exemptionsFilePath) } +func severityLevel(severity foasdiff.Severity) int { + switch severity { + case foasdiff.SeverityError: + return 3 + case foasdiff.SeverityWarning: + return 2 + case foasdiff.SeverityInfo: + return 1 + } + return 1 +} + func transformEntries(entries []*OasDiffEntry, exemptionsFilePath string) ([]*OasDiffEntry, error) { fs := afero.NewOsFs() entries, err := MarkHiddenEntries(entries, exemptionsFilePath, fs) diff --git a/tools/foas/diff/classify.go b/tools/foas/diff/classify.go new file mode 100644 index 0000000000..beca98365d --- /dev/null +++ b/tools/foas/diff/classify.go @@ -0,0 +1,47 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package diff + +import "strings" + +func classifyChange(id string) (Component, ChangeType) { + return componentFromID(id), changeTypeFromID(id) +} + +func componentFromID(id string) Component { + switch { + case strings.Contains(id, "parameter"): + return ComponentParameter + case strings.Contains(id, "request-body"), strings.Contains(id, "request-property"): + return ComponentRequestBody + case strings.Contains(id, "response"): + return ComponentResponse + case strings.Contains(id, "schema"): + return ComponentSchema + default: + return ComponentEndpoint + } +} + +func changeTypeFromID(id string) ChangeType { + switch { + case strings.Contains(id, "removed"): + return ChangeTypeDeleted + case strings.Contains(id, "added"): + return ChangeTypeAdded + default: + return ChangeTypeModified + } +} diff --git a/tools/foas/diff/compare.go b/tools/foas/diff/compare.go new file mode 100644 index 0000000000..a561288617 --- /dev/null +++ b/tools/foas/diff/compare.go @@ -0,0 +1,310 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package diff + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "sort" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/oasdiff/oasdiff/checker" + oasdiff "github.com/oasdiff/oasdiff/diff" + "github.com/oasdiff/oasdiff/flatten/allof" + "github.com/oasdiff/oasdiff/load" +) + +// Compare compares a base OpenAPI document with a revision using FOAS +// compatibility rules. +func Compare(ctx context.Context, base, revision Document) (Report, error) { + if err := ctx.Err(); err != nil { + return Report{}, err + } + if base.Spec == nil { + return Report{}, errors.New("base OpenAPI document is required") + } + if revision.Spec == nil { + return Report{}, errors.New("revision OpenAPI document is required") + } + + baseSpec, revisionSpec, err := prepareSpecs(base.Spec, revision.Spec) + if err != nil { + return Report{}, err + } + + diffConfig := oasdiff.NewConfig() + diffConfig.IncludePathParams = true + report, sourceMap, err := oasdiff.GetWithOperationsSourcesMap( + diffConfig, + &load.SpecInfo{Spec: baseSpec, Url: base.Source}, + &load.SpecInfo{Spec: revisionSpec, Url: revision.Source}, + ) + if err != nil { + return Report{}, fmt.Errorf("compute OpenAPI diff: %w", err) + } + + result := Report{ + RulesetVersion: RulesetVersion, + Engine: "oasdiff", + Changes: make([]Change, 0), + } + if report == nil { + return result, nil + } + + if err := ctx.Err(); err != nil { + return Report{}, err + } + + checkerChanges := checker.CheckBackwardCompatibilityUntilLevel( + newCheckerConfig(), + report, + sourceMap, + checker.INFO, + ) + localizer := checker.NewDefaultLocalizer() + result.Changes = make([]Change, 0, len(checkerChanges)) + for _, checkerChange := range checkerChanges { + result.Changes = append(result.Changes, normalizeCheckerChange(checkerChange, localizer)) + } + + result.Changes = appendMissingAdditions(result.Changes, report) + + sortChanges(result.Changes) + result.Summary = summarize(result.Changes) + result.HasChanges = result.Summary.Total > 0 + return result, nil +} + +func prepareSpecs(base, revision *openapi3.T) (flattenedBase, flattenedRevision *openapi3.T, err error) { + flattenedBase, err = allof.MergeSpec(base) + if err != nil { + return nil, nil, fmt.Errorf("flatten base OpenAPI document: %w", err) + } + flattenedRevision, err = allof.MergeSpec(revision) + if err != nil { + return nil, nil, fmt.Errorf("flatten revision OpenAPI document: %w", err) + } + return flattenedBase, flattenedRevision, nil +} + +func normalizeCheckerChange(change checker.Change, localizer checker.Localizer) Change { + component, changeType := classifyChange(change.GetId()) + severity := severityFromChecker(change.GetLevel()) + result := Change{ + ID: change.GetId(), + Text: change.GetUncolorizedText(localizer), + Severity: severity, + Breaking: severity == SeverityError, + Origin: OriginChecker, + Component: component, + ChangeType: changeType, + Operation: change.GetOperation(), + OperationID: change.GetOperationId(), + Path: change.GetPath(), + Source: change.GetSource(), + Section: change.GetSection(), + BaseLocation: sourceLocation(change.GetBaseSource()), + RevisionLocation: sourceLocation(change.GetRevisionSource()), + } + result.Fingerprint = fingerprint(&result) + return result +} + +func severityFromChecker(level checker.Level) Severity { + switch { + case level >= checker.ERR: + return SeverityError + case level >= checker.WARN: + return SeverityWarning + default: + return SeverityInfo + } +} + +func sourceLocation(source *checker.Source) *SourceLocation { + if source == nil { + return nil + } + return &SourceLocation{ + File: source.File, + Line: source.Line, + Column: source.Column, + EndLine: source.EndLine, + EndColumn: source.EndColumn, + } +} + +func appendMissingAdditions(changes []Change, report *oasdiff.Diff) []Change { + existing := make(map[string]struct{}, len(changes)) + for index := range changes { + existing[changeIdentity(&changes[index])] = struct{}{} + } + + additions := structuralAdditions(report) + for index := range additions { + change := additions[index] + key := changeIdentity(&change) + if _, found := existing[key]; found { + continue + } + existing[key] = struct{}{} + changes = append(changes, change) + } + return changes +} + +func structuralAdditions(report *oasdiff.Diff) []Change { + var changes []Change + + if report.EndpointsDiff != nil { + for _, endpoint := range report.EndpointsDiff.Added { + change := Change{ + ID: "endpoint-added", + Text: fmt.Sprintf("endpoint %s %s added", endpoint.Method, endpoint.Path), + Severity: SeverityInfo, + Origin: OriginStructural, + Component: ComponentEndpoint, + ChangeType: ChangeTypeAdded, + Operation: endpoint.Method, + Path: endpoint.Path, + } + change.Fingerprint = fingerprint(&change) + changes = append(changes, change) + } + } + + if report.ComponentsDiff == nil { + return changes + } + + components := report.ComponentsDiff + if components.SchemasDiff != nil { + changes = append(changes, namedAdditions("api-schema-added", ComponentSchema, "schema", components.SchemasDiff.Added)...) + } + if components.ParametersDiff != nil { + changes = append(changes, namedAdditions("api-parameter-added", ComponentParameter, "parameter", components.ParametersDiff.Added)...) + } + if components.HeadersDiff != nil { + changes = append(changes, namedAdditions("api-header-added", ComponentHeader, "header", components.HeadersDiff.Added)...) + } + if components.RequestBodiesDiff != nil { + changes = append(changes, namedAdditions( + "api-request-body-added", + ComponentRequestBody, + "request body", + components.RequestBodiesDiff.Added, + )...) + } + if components.ResponsesDiff != nil { + changes = append(changes, namedAdditions("api-response-added", ComponentResponse, "response", components.ResponsesDiff.Added)...) + } + if components.SecuritySchemesDiff != nil { + changes = append(changes, namedAdditions( + "api-security-scheme-added", + ComponentSecurityScheme, + "security scheme", + components.SecuritySchemesDiff.Added, + )...) + } + if components.ExamplesDiff != nil { + changes = append(changes, namedAdditions("api-example-added", ComponentExample, "example", components.ExamplesDiff.Added)...) + } + if components.LinksDiff != nil { + changes = append(changes, namedAdditions("api-link-added", ComponentLink, "link", components.LinksDiff.Added)...) + } + if components.CallbacksDiff != nil { + changes = append(changes, namedAdditions("api-callback-added", ComponentCallback, "callback", components.CallbacksDiff.Added)...) + } + return changes +} + +func namedAdditions(id string, component Component, label string, names []string) []Change { + changes := make([]Change, 0, len(names)) + for _, name := range names { + change := Change{ + ID: id, + Text: fmt.Sprintf("%s %q added", label, name), + Severity: SeverityInfo, + Origin: OriginStructural, + Component: component, + ChangeType: ChangeTypeAdded, + Name: name, + } + change.Fingerprint = fingerprint(&change) + changes = append(changes, change) + } + return changes +} + +func changeIdentity(change *Change) string { + return strings.Join([]string{ + string(change.Component), + string(change.ChangeType), + change.Operation, + change.Path, + change.Name, + }, "\x00") +} + +func fingerprint(change *Change) string { + value := strings.Join([]string{ + change.ID, + string(change.Component), + string(change.ChangeType), + change.Operation, + change.OperationID, + change.Path, + change.Name, + change.Text, + }, "\x00") + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("sha256:%x", sum[:16]) +} + +func sortChanges(changes []Change) { + sort.SliceStable(changes, func(i, j int) bool { + left, right := &changes[i], &changes[j] + if left.Breaking != right.Breaking { + return left.Breaking + } + if left.Path != right.Path { + return left.Path < right.Path + } + if left.Operation != right.Operation { + return left.Operation < right.Operation + } + if left.Name != right.Name { + return left.Name < right.Name + } + return left.ID < right.ID + }) +} + +func summarize(changes []Change) Summary { + summary := Summary{Total: len(changes)} + for index := range changes { + change := &changes[index] + if change.Breaking { + summary.Breaking++ + continue + } + summary.NonBreaking++ + } + return summary +} diff --git a/tools/foas/diff/compare_test.go b/tools/foas/diff/compare_test.go new file mode 100644 index 0000000000..31e7e51a1a --- /dev/null +++ b/tools/foas/diff/compare_test.go @@ -0,0 +1,236 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package diff + +import ( + "context" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompareIdenticalDocuments(t *testing.T) { + report, err := Compare( + context.Background(), + Document{Spec: endpointSpec(true, false, "listItems")}, + Document{Spec: endpointSpec(true, false, "listItems")}, + ) + require.NoError(t, err) + + assert.False(t, report.HasChanges) + assert.Empty(t, report.Changes) + assert.Equal(t, Summary{}, report.Summary) + assert.Equal(t, RulesetVersion, report.RulesetVersion) + assert.Equal(t, "oasdiff", report.Engine) +} + +func TestCompareAddedEndpointIsNonBreaking(t *testing.T) { + report, err := Compare( + context.Background(), + Document{Spec: endpointSpec(true, false, "listItems")}, + Document{Spec: endpointSpec(true, true, "listItems")}, + ) + require.NoError(t, err) + + change := findChange(t, report.Changes, func(change Change) bool { + return change.Component == ComponentEndpoint && + change.ChangeType == ChangeTypeAdded + }) + assert.False(t, change.Breaking) + assert.Equal(t, SeverityInfo, change.Severity) + assert.Equal(t, "DELETE", change.Operation) + assert.Equal(t, "/items", change.Path) + assert.NotEmpty(t, change.Fingerprint) + assert.Equal(t, 0, report.Summary.Breaking) + + addedEndpoints := 0 + for _, change := range report.Changes { + if change.Component == ComponentEndpoint && change.ChangeType == ChangeTypeAdded { + addedEndpoints++ + } + } + assert.Equal(t, 1, addedEndpoints, "checker and structural additions must be deduplicated") +} + +func TestCompareRemovedEndpointIsBreaking(t *testing.T) { + report, err := Compare( + context.Background(), + Document{Spec: endpointSpec(true, true, "listItems")}, + Document{Spec: endpointSpec(false, true, "listItems")}, + ) + require.NoError(t, err) + + change := findChange(t, report.Changes, func(change Change) bool { + return change.Breaking && + change.Component == ComponentEndpoint && + change.Path == "/items" && + change.Operation == "GET" + }) + assert.Equal(t, SeverityError, change.Severity) + assert.Equal(t, ChangeTypeDeleted, change.ChangeType, "%#v", change) + assert.Positive(t, report.Summary.Breaking) +} + +func TestCompareAppliesOperationIDRemovalRule(t *testing.T) { + report, err := Compare( + context.Background(), + Document{Spec: endpointSpec(true, false, "listItems")}, + Document{Spec: endpointSpec(true, false, "")}, + ) + require.NoError(t, err) + + change := findChange(t, report.Changes, func(change Change) bool { + return change.ID == "api-operation-id-removed" + }) + assert.True(t, change.Breaking) + assert.Equal(t, SeverityError, change.Severity) +} + +func TestCompareWarningIsNonBreaking(t *testing.T) { + report, err := Compare( + context.Background(), + Document{Spec: parameterSpec(false)}, + Document{Spec: parameterSpec(true)}, + ) + require.NoError(t, err) + + change := findChange(t, report.Changes, func(change Change) bool { + return change.ID == "request-parameter-max-set" + }) + assert.False(t, change.Breaking) + assert.Equal(t, SeverityWarning, change.Severity) +} + +func TestCompareAddedSchema(t *testing.T) { + base := endpointSpec(true, false, "listItems") + revision := endpointSpec(true, false, "listItems") + revision.Components.Schemas["Region"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}, + } + + report, err := Compare( + context.Background(), + Document{Spec: base}, + Document{Spec: revision}, + ) + require.NoError(t, err) + + change := findChange(t, report.Changes, func(change Change) bool { + return change.Component == ComponentSchema && + change.ChangeType == ChangeTypeAdded && + change.Name == "Region" + }) + assert.False(t, change.Breaking) + assert.Equal(t, "api-schema-added", change.ID) +} + +func TestCompareRejectsMissingDocumentsAndCancelledContext(t *testing.T) { + _, err := Compare(context.Background(), Document{}, Document{Spec: endpointSpec(true, false, "listItems")}) + require.EqualError(t, err, "base OpenAPI document is required") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = Compare( + ctx, + Document{Spec: endpointSpec(true, false, "listItems")}, + Document{Spec: endpointSpec(true, false, "listItems")}, + ) + assert.ErrorIs(t, err, context.Canceled) +} + +func endpointSpec(includeGet, includeDelete bool, operationID string) *openapi3.T { + spec := newSpec() + pathItem := &openapi3.PathItem{} + if includeGet { + pathItem.Get = &openapi3.Operation{ + OperationID: operationID, + Tags: []string{"Items"}, + Responses: successfulResponses(), + } + } + if includeDelete { + pathItem.Delete = &openapi3.Operation{ + OperationID: "deleteItems", + Tags: []string{"Items"}, + Responses: successfulResponses(), + } + } + spec.Paths.Set("/items", pathItem) + return spec +} + +func parameterSpec(withMaximum bool) *openapi3.T { + spec := newSpec() + schema := &openapi3.Schema{Type: &openapi3.Types{"integer"}} + if withMaximum { + maximum := 100.0 + schema.Max = &maximum + } + spec.Paths.Set("/items", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "listItems", + Parameters: openapi3.Parameters{ + {Value: &openapi3.Parameter{ + Name: "limit", + In: "query", + Schema: &openapi3.SchemaRef{Value: schema}, + }}, + }, + Responses: successfulResponses(), + }, + }) + return spec +} + +func newSpec() *openapi3.T { + return &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: "Items API", + Version: "1.0.0", + }, + Paths: &openapi3.Paths{}, + Components: &openapi3.Components{ + Schemas: map[string]*openapi3.SchemaRef{ + "Item": { + Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}, + }, + }, + }, + } +} + +func successfulResponses() *openapi3.Responses { + description := "OK" + responses := openapi3.NewResponses() + responses.Set("200", &openapi3.ResponseRef{ + Value: &openapi3.Response{Description: &description}, + }) + return responses +} + +func findChange(t *testing.T, changes []Change, matches func(Change) bool) Change { + t.Helper() + for index := range changes { + change := changes[index] + if matches(change) { + return change + } + } + t.Fatalf("matching change not found in %#v", changes) + return Change{} +} diff --git a/tools/foas/diff/doc.go b/tools/foas/diff/doc.go new file mode 100644 index 0000000000..4721bd0909 --- /dev/null +++ b/tools/foas/diff/doc.go @@ -0,0 +1,21 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package diff compares OpenAPI documents using the compatibility rules +// defined by FOAS. +// +// The package deliberately does not expose oasdiff types. Callers receive a +// stable FOAS report that can be consumed by the changelog generator, OASIS, +// CLIs, or other Go applications. +package diff diff --git a/tools/foas/diff/rules.go b/tools/foas/diff/rules.go new file mode 100644 index 0000000000..a7e7708599 --- /dev/null +++ b/tools/foas/diff/rules.go @@ -0,0 +1,46 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package diff + +import "github.com/oasdiff/oasdiff/checker" + +const ( + deprecationDaysStable = 365 + deprecationDaysBeta = 365 +) + +var severityOverrides = map[string]checker.Level{ + "response-non-success-status-removed": checker.ERR, + "api-operation-id-removed": checker.ERR, + "api-tag-removed": checker.ERR, + "response-property-enum-value-removed": checker.ERR, + "response-mediatype-enum-value-removed": checker.ERR, + "request-body-enum-value-removed": checker.ERR, + "api-schema-removed": checker.ERR, + "response-property-one-of-added": checker.INFO, + "response-body-one-of-added": checker.INFO, + "request-parameter-removed": checker.ERR, + "request-property-removed": checker.ERR, + "response-optional-property-removed": checker.ERR, + "response-optional-write-only-property-removed": checker.ERR, +} + +func newCheckerConfig() *checker.Config { + return checker.NewConfig( + checker.GetAllChecks(), + checker.WithSeverityLevels(severityOverrides), + checker.WithDeprecation(deprecationDaysBeta, deprecationDaysStable), + ) +} diff --git a/tools/foas/diff/types.go b/tools/foas/diff/types.go new file mode 100644 index 0000000000..472bcfb62e --- /dev/null +++ b/tools/foas/diff/types.go @@ -0,0 +1,114 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package diff + +import "github.com/getkin/kin-openapi/openapi3" + +// RulesetVersion changes whenever FOAS compatibility classification changes. +const RulesetVersion = "1" + +// Document is an OpenAPI document and its optional source identifier. +type Document struct { + Spec *openapi3.T `json:"-"` + Source string `json:"source,omitempty"` +} + +// Severity is the FOAS severity assigned to a change. +type Severity string + +const ( + SeverityInfo Severity = "info" + SeverityWarning Severity = "warning" + SeverityError Severity = "error" +) + +// Origin identifies how a change was discovered. +type Origin string + +const ( + OriginChecker Origin = "checker" + OriginStructural Origin = "structural" +) + +// Component identifies the broad OpenAPI component affected by a change. +type Component string + +const ( + ComponentEndpoint Component = "endpoint" + ComponentSchema Component = "schema" + ComponentParameter Component = "parameter" + ComponentHeader Component = "header" + ComponentRequestBody Component = "requestBody" + ComponentResponse Component = "response" + ComponentSecurityScheme Component = "securityScheme" + ComponentExample Component = "example" + ComponentLink Component = "link" + ComponentCallback Component = "callback" +) + +// ChangeType is a coarse classification intended for filtering and display. +type ChangeType string + +const ( + ChangeTypeAdded ChangeType = "added" + ChangeTypeDeleted ChangeType = "deleted" + ChangeTypeModified ChangeType = "modified" +) + +// SourceLocation identifies a source range when it is available from the +// comparison engine. +type SourceLocation struct { + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + EndLine int `json:"endLine,omitempty"` + EndColumn int `json:"endColumn,omitempty"` +} + +// Change is one normalized compatibility or structural change. +type Change struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + Text string `json:"text"` + Severity Severity `json:"severity"` + Breaking bool `json:"breaking"` + Origin Origin `json:"origin"` + Component Component `json:"component"` + ChangeType ChangeType `json:"changeType"` + Operation string `json:"operation,omitempty"` + OperationID string `json:"operationId,omitempty"` + Path string `json:"path,omitempty"` + Name string `json:"name,omitempty"` + Source string `json:"source,omitempty"` + Section string `json:"section,omitempty"` + BaseLocation *SourceLocation `json:"baseLocation,omitempty"` + RevisionLocation *SourceLocation `json:"revisionLocation,omitempty"` +} + +// Summary contains full-report counts. +type Summary struct { + Total int `json:"total"` + Breaking int `json:"breaking"` + NonBreaking int `json:"nonBreaking"` +} + +// Report is the transport-neutral result of comparing two OpenAPI documents. +type Report struct { + HasChanges bool `json:"hasChanges"` + Summary Summary `json:"summary"` + Changes []Change `json:"changes"` + RulesetVersion string `json:"rulesetVersion"` + Engine string `json:"engine"` +} From d5d16e2bd22ff8b6ba5788bdc6cbae120f41c7c9 Mon Sep 17 00:00:00 2001 From: Andrei Matei Date: Fri, 7 Aug 2026 21:06:52 +0100 Subject: [PATCH 2/2] feat(foas): add extensible diff rule registry --- tools/foas/diff/compare.go | 33 +++-- tools/foas/diff/doc.go | 11 ++ tools/foas/diff/rule_registry.go | 135 ++++++++++++++++++++ tools/foas/diff/rule_registry_test.go | 173 ++++++++++++++++++++++++++ tools/foas/diff/rules.go | 25 +++- 5 files changed, 365 insertions(+), 12 deletions(-) create mode 100644 tools/foas/diff/rule_registry.go create mode 100644 tools/foas/diff/rule_registry_test.go diff --git a/tools/foas/diff/compare.go b/tools/foas/diff/compare.go index a561288617..679cec3cdc 100644 --- a/tools/foas/diff/compare.go +++ b/tools/foas/diff/compare.go @@ -32,8 +32,12 @@ import ( // Compare compares a base OpenAPI document with a revision using FOAS // compatibility rules. func Compare(ctx context.Context, base, revision Document) (Report, error) { - if err := ctx.Err(); err != nil { - return Report{}, err + return compareWithCustomRules(ctx, base, revision, registeredCustomRules()) +} + +func compareWithCustomRules(ctx context.Context, base, revision Document, customRules []customRule) (Report, error) { + if contextErr := ctx.Err(); contextErr != nil { + return Report{}, contextErr } if base.Spec == nil { return Report{}, errors.New("base OpenAPI document is required") @@ -67,20 +71,25 @@ func Compare(ctx context.Context, base, revision Document) (Report, error) { return result, nil } - if err := ctx.Err(); err != nil { - return Report{}, err + if contextErr := ctx.Err(); contextErr != nil { + return Report{}, contextErr } + checkerConfig, err := newCheckerConfig(customRules) + if err != nil { + return Report{}, fmt.Errorf("configure compatibility checks: %w", err) + } checkerChanges := checker.CheckBackwardCompatibilityUntilLevel( - newCheckerConfig(), + checkerConfig, report, sourceMap, checker.INFO, ) localizer := checker.NewDefaultLocalizer() + customMessages := customRuleMessages(customRules) result.Changes = make([]Change, 0, len(checkerChanges)) for _, checkerChange := range checkerChanges { - result.Changes = append(result.Changes, normalizeCheckerChange(checkerChange, localizer)) + result.Changes = append(result.Changes, normalizeCheckerChange(checkerChange, localizer, customMessages)) } result.Changes = appendMissingAdditions(result.Changes, report) @@ -103,12 +112,20 @@ func prepareSpecs(base, revision *openapi3.T) (flattenedBase, flattenedRevision return flattenedBase, flattenedRevision, nil } -func normalizeCheckerChange(change checker.Change, localizer checker.Localizer) Change { +func normalizeCheckerChange( + change checker.Change, + localizer checker.Localizer, + customMessages map[string]ruleMessage, +) Change { component, changeType := classifyChange(change.GetId()) severity := severityFromChecker(change.GetLevel()) + text := change.GetUncolorizedText(localizer) + if message, exists := customMessages[change.GetId()]; exists { + text = message(change.GetArgs()) + } result := Change{ ID: change.GetId(), - Text: change.GetUncolorizedText(localizer), + Text: text, Severity: severity, Breaking: severity == SeverityError, Origin: OriginChecker, diff --git a/tools/foas/diff/doc.go b/tools/foas/diff/doc.go index 4721bd0909..7c60f7c36e 100644 --- a/tools/foas/diff/doc.go +++ b/tools/foas/diff/doc.go @@ -18,4 +18,15 @@ // The package deliberately does not expose oasdiff types. Callers receive a // stable FOAS report that can be consumed by the changelog generator, OASIS, // CLIs, or other Go applications. +// +// To add a custom compatibility rule: +// - implement a checker in a dedicated check_.go file; +// - describe it with newCustomRule, including its FOAS message formatter; +// - add it to registeredCustomRules; +// - add focused checker and report tests; +// - increment RulesetVersion when the observable classification changes. +// +// Rules sharing one checker handler are executed once. The registry rejects +// duplicate IDs, collisions with built-in oasdiff rules, missing metadata, +// unsupported severities, and nil handlers or message formatters. package diff diff --git a/tools/foas/diff/rule_registry.go b/tools/foas/diff/rule_registry.go new file mode 100644 index 0000000000..dcdcdb431b --- /dev/null +++ b/tools/foas/diff/rule_registry.go @@ -0,0 +1,135 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package diff + +import ( + "errors" + "fmt" + "reflect" + + "github.com/oasdiff/oasdiff/checker" +) + +type ruleMessage func(args []any) string + +// customRule keeps the oasdiff execution metadata and the FOAS-owned message +// formatter together. Add each approved custom rule to registeredCustomRules. +type customRule struct { + id string + severity Severity + description string + handler checker.BackwardCompatibilityCheck + direction checker.Direction + area checker.Area + kind checker.Kind + action checker.Action + message ruleMessage +} + +func newCustomRule( + id string, + severity Severity, + description string, + handler checker.BackwardCompatibilityCheck, + direction checker.Direction, + area checker.Area, + kind checker.Kind, + action checker.Action, + message ruleMessage, +) customRule { + return customRule{ + id: id, + severity: severity, + description: description, + handler: handler, + direction: direction, + area: area, + kind: kind, + action: action, + message: message, + } +} + +// registeredCustomRules is the single registry for approved FOAS-specific +// compatibility rules. A new rule normally consists of one checker file and +// one entry in this slice. +func registeredCustomRules() []customRule { + return nil +} + +func validateCustomRules(rules []customRule) error { + builtInLevels := checker.GetCheckLevels() + ids := make(map[string]struct{}, len(rules)) + for _, rule := range rules { + if rule.id == "" { + return errors.New("custom diff rule ID is required") + } + if _, exists := builtInLevels[rule.id]; exists { + return fmt.Errorf("custom diff rule %q conflicts with an oasdiff rule", rule.id) + } + if _, exists := ids[rule.id]; exists { + return fmt.Errorf("custom diff rule %q is registered more than once", rule.id) + } + if rule.description == "" { + return fmt.Errorf("custom diff rule %q description is required", rule.id) + } + if rule.handler == nil { + return fmt.Errorf("custom diff rule %q checker is required", rule.id) + } + if rule.message == nil { + return fmt.Errorf("custom diff rule %q message formatter is required", rule.id) + } + if _, err := checkerLevel(rule.severity); err != nil { + return fmt.Errorf("custom diff rule %q: %w", rule.id, err) + } + ids[rule.id] = struct{}{} + } + return nil +} + +func customChecks(rules []customRule) checker.BackwardCompatibilityChecks { + checks := make(checker.BackwardCompatibilityChecks, 0, len(rules)) + handlers := make(map[uintptr]struct{}, len(rules)) + for _, rule := range rules { + pointer := reflect.ValueOf(rule.handler).Pointer() + if _, exists := handlers[pointer]; exists { + continue + } + handlers[pointer] = struct{}{} + checks = append(checks, rule.handler) + } + return checks +} + +func customRuleMessages(rules []customRule) map[string]ruleMessage { + messages := make(map[string]ruleMessage, len(rules)) + for _, rule := range rules { + messages[rule.id] = rule.message + } + return messages +} + +func checkerLevel(severity Severity) (checker.Level, error) { + switch severity { + case SeverityInfo: + return checker.INFO, nil + case SeverityWarning: + return checker.WARN, nil + case SeverityError: + return checker.ERR, nil + default: + return 0, fmt.Errorf("unsupported severity %q", severity) + } +} diff --git a/tools/foas/diff/rule_registry_test.go b/tools/foas/diff/rule_registry_test.go new file mode 100644 index 0000000000..2039327339 --- /dev/null +++ b/tools/foas/diff/rule_registry_test.go @@ -0,0 +1,173 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package diff + +import ( + "context" + "fmt" + "testing" + + "github.com/oasdiff/oasdiff/checker" + oasdiff "github.com/oasdiff/oasdiff/diff" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testExtensionUpdatedID = "api-test-extension-updated" + +func TestCompareRunsCustomRules(t *testing.T) { + base := endpointSpec(true, false, "listItems") + revision := endpointSpec(true, false, "listItems") + base.Paths.Value("/items").Get.Extensions = map[string]any{"x-test-extension": "old"} + revision.Paths.Value("/items").Get.Extensions = map[string]any{"x-test-extension": "new"} + + report, err := compareWithCustomRules( + context.Background(), + Document{Spec: base}, + Document{Spec: revision}, + []customRule{testExtensionRule()}, + ) + require.NoError(t, err) + + change := findChange(t, report.Changes, func(change Change) bool { + return change.ID == testExtensionUpdatedID + }) + assert.Equal(t, SeverityError, change.Severity) + assert.True(t, change.Breaking) + assert.Equal(t, "x-test-extension changed from \"old\" to \"new\"", change.Text) + assert.Equal(t, "/items", change.Path) + assert.Equal(t, "GET", change.Operation) +} + +func TestValidateCustomRules(t *testing.T) { + valid := testExtensionRule() + + tests := []struct { + name string + rules []customRule + error string + }{ + { + name: "DuplicateID", + rules: []customRule{valid, valid}, + error: `custom diff rule "api-test-extension-updated" is registered more than once`, + }, + { + name: "BuiltInID", + rules: []customRule{ + newCustomRule( + "api-operation-id-removed", + SeverityError, + "conflicting rule", + testExtensionUpdatedCheck, + checker.DirectionNone, + checker.AreaPaths, + checker.KindType, + checker.ActionChange, + testExtensionMessage, + ), + }, + error: `custom diff rule "api-operation-id-removed" conflicts with an oasdiff rule`, + }, + { + name: "MissingHandler", + rules: []customRule{ + newCustomRule( + "missing-handler", + SeverityError, + "missing handler", + nil, + checker.DirectionNone, + checker.AreaPaths, + checker.KindType, + checker.ActionChange, + testExtensionMessage, + ), + }, + error: `custom diff rule "missing-handler" checker is required`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.EqualError(t, validateCustomRules(test.rules), test.error) + }) + } +} + +func TestCustomChecksDeduplicatesSharedHandlers(t *testing.T) { + first := testExtensionRule() + second := first + second.id = "api-second-test-extension-updated" + + assert.Len(t, customChecks([]customRule{first, second}), 1) +} + +func testExtensionRule() customRule { + return newCustomRule( + testExtensionUpdatedID, + SeverityError, + "an operation extension changed", + testExtensionUpdatedCheck, + checker.DirectionNone, + checker.AreaPaths, + checker.KindType, + checker.ActionChange, + testExtensionMessage, + ) +} + +func testExtensionMessage(args []any) string { + return fmt.Sprintf("x-test-extension changed from %q to %q", args[0], args[1]) +} + +func testExtensionUpdatedCheck( + report *oasdiff.Diff, + operationSources *oasdiff.OperationsSourcesMap, + config *checker.Config, +) checker.Changes { + var changes checker.Changes + if report.PathsDiff == nil { + return changes + } + + for path, pathItem := range report.PathsDiff.Modified { + if pathItem.OperationsDiff == nil { + continue + } + for operation, operationItem := range pathItem.OperationsDiff.Modified { + if operationItem.ExtensionsDiff == nil { + continue + } + if operationItem.ExtensionsDiff.Modified["x-test-extension"] == nil { + continue + } + changes = append(changes, checker.NewApiChange( + testExtensionUpdatedID, + config, + []any{ + operationItem.Base.Extensions["x-test-extension"], + operationItem.Revision.Extensions["x-test-extension"], + }, + "", + operationSources, + pathItem.Revision.GetOperation(operation), + operation, + path, + )) + } + } + return changes +} diff --git a/tools/foas/diff/rules.go b/tools/foas/diff/rules.go index a7e7708599..b49a349d36 100644 --- a/tools/foas/diff/rules.go +++ b/tools/foas/diff/rules.go @@ -14,7 +14,11 @@ package diff -import "github.com/oasdiff/oasdiff/checker" +import ( + "fmt" + + "github.com/oasdiff/oasdiff/checker" +) const ( deprecationDaysStable = 365 @@ -37,10 +41,23 @@ var severityOverrides = map[string]checker.Level{ "response-optional-write-only-property-removed": checker.ERR, } -func newCheckerConfig() *checker.Config { - return checker.NewConfig( - checker.GetAllChecks(), +func newCheckerConfig(customRules []customRule) (*checker.Config, error) { + if err := validateCustomRules(customRules); err != nil { + return nil, err + } + + checks := append(checker.GetAllChecks(), customChecks(customRules)...) + config := checker.NewConfig( + checks, checker.WithSeverityLevels(severityOverrides), checker.WithDeprecation(deprecationDaysBeta, deprecationDaysStable), ) + for _, rule := range customRules { + level, err := checkerLevel(rule.severity) + if err != nil { + return nil, fmt.Errorf("configure custom diff rule %q: %w", rule.id, err) + } + config.LogLevels[rule.id] = level + } + return config, nil }