Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions tools/foas/breakingchanges/doc.go
Original file line number Diff line number Diff line change
@@ -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
33 changes: 1 addition & 32 deletions tools/foas/changelog/changelog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -371,23 +349,14 @@ 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,
Base: baseSpec,
Revision: revisionSpec,
BaseMetadata: baseMetadata,
RevisionMetadata: revisionMetadata,
Config: changelogConfig,
ExemptionFilePath: exceptionFilePath,
OasDiff: openapi.NewOasDiffWithSpecInfo(baseSpec, revisionSpec, &diff.Config{
IncludePathParams: true,
}),
}, nil
}

Expand Down
27 changes: 17 additions & 10 deletions tools/foas/changelog/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 42 additions & 3 deletions tools/foas/changelog/outputfilter/outputfilter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions tools/foas/diff/classify.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading