Skip to content
Merged
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
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ What about a terminal UI that does the same?
See all the documentation at https://pb33f.io/openapi-changes/

- [Installing openapi-changes](https://pb33f.io/openapi-changes/installing/)
- [Configuring breaking changes](https://pb33f.io/openapi-changes/configuring/)
- [Command arguments](https://pb33f.io/openapi-changes/command-arguments/)
- CLI Commands
- [`console` command](https://pb33f.io/openapi-changes/console/)
Expand Down Expand Up @@ -99,4 +100,84 @@ docker run --rm -v $PWD:/work:rw pb33f/openapi-changes summary . sample-specs/pe

---

## Custom Breaking Rules Configuration

> Supported in `v0.91+`

openapi-changes uses [libopenapi](https://github.com/pb33f/libopenapi)'s configurable breaking change
detection system. You can customize which changes are considered "breaking" by providing a configuration file.

### Using a Config File

```bash
# Use explicit config file
openapi-changes summary -c my-rules.yaml old.yaml new.yaml

# Or place changes-rules.yaml in current directory (auto-detected)
openapi-changes summary old.yaml new.yaml
```

### Default Config Locations

openapi-changes searches for `changes-rules.yaml` in:
1. Current working directory (`./changes-rules.yaml`)
2. User config directory (`~/.config/changes-rules.yaml`)

### Example Configuration

Create a `changes-rules.yaml` file:

```yaml
# Custom breaking rules configuration
# Only specify overrides - unspecified rules use defaults

# Make operation removal non-breaking (for deprecation workflows)
pathItem:
get:
removed: false
post:
removed: false
put:
removed: false
delete:
removed: false

# Make enum value removal non-breaking
schema:
enum:
removed: false

# Make parameter changes non-breaking
parameter:
required:
modified: false
```

### Configuration Structure

Each rule has three options:
- `added`: Is adding this property a breaking change? (true/false)
- `modified`: Is modifying this property a breaking change? (true/false)
- `removed`: Is removing this property a breaking change? (true/false)

### Available Components

You can configure rules for these OpenAPI components:

| Component | Description |
|-----------------------|----------------------------------------------------|
| `paths` | Path definitions |
| `pathItem` | Operations (get, post, put, delete, etc.) |
| `operation` | Operation details (operationId, requestBody, etc.) |
| `parameter` | Parameter properties (name, required, schema) |
| `schema` | Schema properties (type, format, enum, properties) |
| `response` | Response definitions |
| `securityScheme` | Security scheme properties |
| `securityRequirement` | Security requirements |

For the complete list of configurable properties and more examples, see the
[full configuration documentation](https://pb33f.io/openapi-changes/configuring/).

---

Check out all the docs at https://pb33f.io/openapi-changes/
98 changes: 76 additions & 22 deletions builder/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@
package builder

import (
"reflect"
"strings"

"github.com/google/uuid"
v3 "github.com/pb33f/libopenapi/datamodel/low/v3"
wcModel "github.com/pb33f/libopenapi/what-changed/model"
"github.com/pb33f/libopenapi/what-changed/reports"
"github.com/pb33f/openapi-changes/internal/security"
"github.com/pb33f/openapi-changes/model"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"reflect"
"strings"
)

var upper cases.Caser
Expand Down Expand Up @@ -50,13 +52,23 @@ func exploreTreeObject(parent *model.TreeNode, object any) {
topChanges := field.Elem().Interface().(wcModel.PropertyChanges).Changes
for x := range topChanges {
title := topChanges[x].Property
if strings.ToLower(topChanges[x].Property) == "codes" {
switch topChanges[x].ChangeType {
case wcModel.Modified, wcModel.PropertyRemoved, wcModel.ObjectRemoved:
title = topChanges[x].Original
break
case wcModel.ObjectAdded, wcModel.PropertyAdded:
title = topChanges[x].New

// Special handling for security scope changes (scheme/scope format)
if security.IsSecurityScopeChange(topChanges[x]) {
title = security.FormatSecurityScopeTitle(topChanges[x])
} else {
lowerProp := strings.ToLower(topChanges[x].Property)
if lowerProp == "codes" || lowerProp == "tags" {
switch topChanges[x].ChangeType {
case wcModel.Modified, wcModel.PropertyRemoved, wcModel.ObjectRemoved:
if topChanges[x].Original != "" {
title = topChanges[x].Original
}
case wcModel.ObjectAdded, wcModel.PropertyAdded:
if topChanges[x].New != "" {
title = topChanges[x].New
}
}
}
}

Expand Down Expand Up @@ -239,7 +251,7 @@ func exploreTreeObject(parent *model.TreeNode, object any) {

case reflect.TypeOf(map[string]*wcModel.CallbackChanges{}):
if !field.IsZero() && len(field.MapKeys()) > 0 {
BuildTreeMapNode(parent, field)
BuildTreeMapNodeWithLabel(parent, field, "Callbacks")
}

case reflect.TypeOf(map[string]*wcModel.ExampleChanges{}):
Expand Down Expand Up @@ -317,29 +329,37 @@ func transformLabel(in string) string {
}

func DigIntoTreeNodeSlice[T any](parent *model.TreeNode, field reflect.Value, label string) {
if !field.IsZero() {
if !field.IsZero() && field.Len() > 0 {
// Create ONE parent node for all elements
parentNode := &model.TreeNode{
TitleString: transformLabel(label),
Key: uuid.New().String(),
IsLeaf: false,
Selectable: false,
Disabled: false,
}

totalChanges := 0
breakingChanges := 0

for k := 0; k < field.Len(); k++ {
f := field.Index(k)
if f.Elem().IsValid() && !f.Elem().IsZero() {
e := &model.TreeNode{
TitleString: transformLabel(label),
Key: uuid.New().String(),
IsLeaf: false,
Selectable: false,
Disabled: false,
}
obj := f.Elem().Interface().(T)
ch, br := countChanges(obj)
if ch > -1 {
e.TotalChanges = ch
totalChanges += ch
}
if br > -1 {
e.BreakingChanges = br
breakingChanges += br
}
parent.Children = append(parent.Children, e)
exploreTreeObject(e, &obj)
exploreTreeObject(parentNode, &obj)
}
}

parentNode.TotalChanges = totalChanges
parentNode.BreakingChanges = breakingChanges
parent.Children = append(parent.Children, parentNode)
}
}

Expand Down Expand Up @@ -370,6 +390,40 @@ func BuildTreeMapNode(parent *model.TreeNode, field reflect.Value) {
}
}

// BuildTreeMapNodeWithLabel creates a labeled parent node and adds map children under it.
// Use this for map fields that should appear as a named section (e.g., "Callbacks").
func BuildTreeMapNodeWithLabel(parent *model.TreeNode, field reflect.Value, label string) {
if !field.IsZero() && len(field.MapKeys()) > 0 {
// Calculate total changes for the label node
totalChanges := 0
breakingChanges := 0
for _, e := range field.MapKeys() {
v := field.MapIndex(e)
if ch, br := countChanges(v.Interface()); ch > -1 {
totalChanges += ch
if br > -1 {
breakingChanges += br
}
}
}

// Create the labeled parent node
labelNode := &model.TreeNode{
TitleString: label,
Key: uuid.New().String(),
IsLeaf: false,
Selectable: false,
Disabled: false,
TotalChanges: totalChanges,
BreakingChanges: breakingChanges,
}
parent.Children = append(parent.Children, labelNode)

// Add map entries as children of the label node
BuildTreeMapNode(labelNode, field)
}
}

func countChanges(i any) (int, int) {
if ch, ok := i.(reports.HasChanges); ok {
return ch.TotalChanges(), ch.TotalBreakingChanges()
Expand Down
Loading
Loading