Skip to content

Commit 4ae14fd

Browse files
feat(merge): normalize operation-level tags and add post-merge validation (#1882)
1 parent 12785ea commit 4ae14fd

File tree

4 files changed

+545
-29
lines changed

4 files changed

+545
-29
lines changed

pkg/merge/merge.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/speakeasy-api/openapi/openapi"
2020
"github.com/speakeasy-api/openapi/overlay"
2121
"github.com/speakeasy-api/openapi/sequencedmap"
22+
openapiValidation "github.com/speakeasy-api/openapi/validation"
2223
"github.com/speakeasy-api/openapi/yml"
2324
"github.com/speakeasy-api/speakeasy/internal/log"
2425
"github.com/speakeasy-api/speakeasy/internal/validation"
@@ -78,6 +79,11 @@ func MergeOpenAPIDocumentsWithNamespaces(ctx context.Context, inputs []MergeInpu
7879
return err
7980
}
8081

82+
// Validate the merged document by re-parsing without skipping validation
83+
if err := validateMergedOutput(ctx, mergedSchema); err != nil {
84+
return err
85+
}
86+
8187
return nil
8288
}
8389

@@ -113,6 +119,31 @@ func validate(ctx context.Context, schemaPath string, schema []byte, defaultRule
113119
return nil
114120
}
115121

122+
// validateMergedOutput re-parses the merged schema with validation enabled
123+
// and returns an error if any validation errors (not warnings) are found.
124+
func validateMergedOutput(ctx context.Context, schema []byte) error {
125+
_, validationErrs, err := openapi.Unmarshal(ctx, bytes.NewReader(schema))
126+
if err != nil {
127+
return fmt.Errorf("merged document failed to parse: %w", err)
128+
}
129+
130+
var errsOut []error
131+
for _, validationErr := range validationErrs {
132+
var ve *openapiValidation.Error
133+
if errors.As(validationErr, &ve) && ve.Severity == openapiValidation.SeverityWarning {
134+
log.From(ctx).Warn(fmt.Sprintf("merged document validation: %s", validationErr.Error()))
135+
} else {
136+
errsOut = append(errsOut, validationErr)
137+
}
138+
}
139+
140+
if len(errsOut) > 0 {
141+
return multierror.Append(fmt.Errorf("merged document is invalid"), errsOut...)
142+
}
143+
144+
return nil
145+
}
146+
116147
func merge(ctx context.Context, inSchemas [][]byte, namespaces []string, yamlOut bool) ([]byte, error) {
117148
// Validate namespace consistency
118149
if err := validateNamespaceSlice(namespaces, len(inSchemas)); err != nil {
@@ -186,6 +217,10 @@ func merge(ctx context.Context, inSchemas [][]byte, namespaces []string, yamlOut
186217
// (ignoring description/summary differences)
187218
deduplicateEquivalentComponents(mergedDoc)
188219

220+
// Post-merge: normalize operation-level tag references to match
221+
// the chosen document-level tag names (case-insensitive)
222+
normalizeOperationTags(mergedDoc)
223+
189224
buf := bytes.NewBuffer(nil)
190225
var err error
191226

@@ -243,6 +278,7 @@ func MergeDocuments(mergedDoc, doc *openapi.OpenAPI) (*openapi.OpenAPI, []error)
243278
initMergeState(state, mergedDoc, "")
244279
merged, errs := mergeDocumentsWithState(state, mergedDoc, doc, "", 2)
245280
deduplicateOperationIds(state, merged)
281+
normalizeOperationTags(merged)
246282
return merged, errs
247283
}
248284

@@ -285,10 +321,11 @@ func mergeDocumentsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI,
285321
}
286322

287323
// Merge Tags (case-insensitive with content-aware disambiguation)
288-
tagRenames := mergeTagsWithState(state, mergedDoc, doc, docNamespace, docCounter)
289-
// Update operation-level tag references in both docs
290-
updateOperationTagRefs(mergedDoc, tagRenames)
291-
updateOperationTagRefs(doc, tagRenames)
324+
tagResult := mergeTagsWithState(state, mergedDoc, doc, docNamespace, docCounter)
325+
// Update operation-level tag references in each doc using its own rename map
326+
// (case-insensitive matching, per-document maps avoid ambiguity)
327+
updateOperationTagRefs(mergedDoc, tagResult.existingRenames)
328+
updateOperationTagRefs(doc, tagResult.incomingRenames)
292329

293330
// Merge Paths (with method-level conflict detection and fragment disambiguation)
294331
pathErrs := mergePathsWithState(state, mergedDoc, doc, docNamespace, docCounter)

pkg/merge/merge_tags.go

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,26 @@ import (
66
"github.com/speakeasy-api/openapi/openapi"
77
)
88

9+
// tagRenameResult holds separate rename maps for the existing (merged) document
10+
// and the incoming document. Keeping them separate allows case-insensitive
11+
// operation-level tag renaming without ambiguity — each map has at most one
12+
// entry per case-insensitive tag name.
13+
type tagRenameResult struct {
14+
existingRenames map[string]string // renames for operations already in mergedDoc
15+
incomingRenames map[string]string // renames for operations in the incoming doc
16+
}
17+
918
// mergeTagsWithState performs case-insensitive tag merging with content-aware disambiguation.
10-
// It returns a map of old tag name → new tag name for updating operation-level tag references.
11-
func mergeTagsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI, docNamespace string, docCounter int) map[string]string {
12-
tagRenames := make(map[string]string)
19+
// It returns separate rename maps for updating operation-level tag references in the
20+
// existing merged document and the incoming document.
21+
func mergeTagsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI, docNamespace string, docCounter int) tagRenameResult {
22+
result := tagRenameResult{
23+
existingRenames: make(map[string]string),
24+
incomingRenames: make(map[string]string),
25+
}
1326

1427
if doc.Tags == nil {
15-
return tagRenames
28+
return result
1629
}
1730

1831
if mergedDoc.Tags == nil {
@@ -53,7 +66,7 @@ func mergeTagsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI, docN
5366
mergedDoc.Tags[matchIdx] = newTag
5467
// Record rename if the name changed (e.g. casing difference)
5568
if oldName != newTag.Name {
56-
tagRenames[oldName] = newTag.Name
69+
result.existingRenames[oldName] = newTag.Name
5770
}
5871
// Update the tracker entry
5972
state.tagTracker[key][matchEntry] = tagEntry{
@@ -70,7 +83,7 @@ func mergeTagsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI, docN
7083
oldName := existingTag.Name
7184
newName := oldName + "_" + existingSuffix
7285
existingTag.Name = newName
73-
tagRenames[oldName] = newName
86+
result.existingRenames[oldName] = newName
7487
state.tagTracker[key][matchEntry] = tagEntry{
7588
currentName: newName,
7689
namespace: entries[matchEntry].namespace,
@@ -82,7 +95,7 @@ func mergeTagsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI, docN
8295
newSuffix := disambiguatingSuffix(docNamespace, docCounter)
8396
oldNewTagName := newTag.Name
8497
newTag.Name = oldNewTagName + "_" + newSuffix
85-
tagRenames[oldNewTagName] = newTag.Name
98+
result.incomingRenames[oldNewTagName] = newTag.Name
8699

87100
mergedDoc.Tags = append(mergedDoc.Tags, newTag)
88101
state.tagTracker[key] = append(state.tagTracker[key], tagEntry{
@@ -93,7 +106,7 @@ func mergeTagsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI, docN
93106
}
94107
}
95108

96-
return tagRenames
109+
return result
97110
}
98111

99112
// findTagInMergedDoc locates the tag in mergedDoc.Tags that corresponds to one
@@ -174,19 +187,25 @@ func ptrStringEqual(a, b *string) bool {
174187
}
175188

176189
// updateOperationTagRefs walks all operations in the document and updates
177-
// tag name references according to the renames map.
190+
// tag name references according to the renames map (case-insensitive).
178191
func updateOperationTagRefs(doc *openapi.OpenAPI, renames map[string]string) {
179192
if len(renames) == 0 {
180193
return
181194
}
182195

196+
// Build case-insensitive lookup once and reuse for all operations.
197+
ciRenames := make(map[string]string, len(renames))
198+
for oldName, newName := range renames {
199+
ciRenames[strings.ToLower(oldName)] = newName
200+
}
201+
183202
if doc.Paths != nil {
184203
for _, pathItem := range doc.Paths.All() {
185204
if pathItem == nil || pathItem.Object == nil {
186205
continue
187206
}
188207
for _, op := range pathItem.Object.All() {
189-
renameOpTags(op, renames)
208+
renameOpTags(op, ciRenames)
190209
}
191210
}
192211
}
@@ -197,20 +216,73 @@ func updateOperationTagRefs(doc *openapi.OpenAPI, renames map[string]string) {
197216
continue
198217
}
199218
for _, op := range pathItem.Object.All() {
200-
renameOpTags(op, renames)
219+
renameOpTags(op, ciRenames)
201220
}
202221
}
203222
}
204223
}
205224

206-
// renameOpTags updates the Tags slice of a single operation.
207-
func renameOpTags(op *openapi.Operation, renames map[string]string) {
225+
// renameOpTags updates the Tags slice of a single operation using a
226+
// pre-built case-insensitive rename map (lowercase keys → new names).
227+
func renameOpTags(op *openapi.Operation, ciRenames map[string]string) {
208228
if op == nil {
209229
return
210230
}
231+
211232
for i, tag := range op.Tags {
212-
if newName, ok := renames[tag]; ok {
233+
if newName, ok := ciRenames[strings.ToLower(tag)]; ok {
213234
op.Tags[i] = newName
214235
}
215236
}
216237
}
238+
239+
// normalizeOperationTags is a post-merge pass that ensures all operation-level
240+
// tag references use the same casing as the document-level tag definitions.
241+
// This catches cases where operations reference tags with a casing that wasn't
242+
// directly involved in a rename (e.g. "PETS" when the rename only covered
243+
// "Pets" → "pets"), or where tags are used only in operations without any
244+
// document-level definition.
245+
func normalizeOperationTags(doc *openapi.OpenAPI) {
246+
// Build canonical name map from document-level tags.
247+
canonical := make(map[string]string, len(doc.Tags))
248+
for _, tag := range doc.Tags {
249+
canonical[strings.ToLower(tag.Name)] = tag.Name
250+
}
251+
252+
normalize := func(op *openapi.Operation) {
253+
if op == nil {
254+
return
255+
}
256+
for i, tag := range op.Tags {
257+
key := strings.ToLower(tag)
258+
if chosen, ok := canonical[key]; ok {
259+
op.Tags[i] = chosen
260+
} else {
261+
// Tag not defined at document level — use first occurrence as canonical.
262+
canonical[key] = tag
263+
}
264+
}
265+
}
266+
267+
if doc.Paths != nil {
268+
for _, pathItem := range doc.Paths.All() {
269+
if pathItem == nil || pathItem.Object == nil {
270+
continue
271+
}
272+
for _, op := range pathItem.Object.All() {
273+
normalize(op)
274+
}
275+
}
276+
}
277+
278+
if doc.Webhooks != nil {
279+
for _, pathItem := range doc.Webhooks.All() {
280+
if pathItem == nil || pathItem.Object == nil {
281+
continue
282+
}
283+
for _, op := range pathItem.Object.All() {
284+
normalize(op)
285+
}
286+
}
287+
}
288+
}

0 commit comments

Comments
 (0)