Skip to content

Commit 1ce7ed5

Browse files
fix: improve openapi join conflict resolution and validation (#45)
1 parent 51345c1 commit 1ce7ed5

17 files changed

+336
-55
lines changed

jsonschema/oas3/resolution.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func (s *JSONSchema[Referenceable]) IsResolved() bool {
2727
return !s.IsReference() || s.resolvedSchemaCache != nil || (s.referenceResolutionCache != nil && s.referenceResolutionCache.Object != nil) || s.circularErrorFound
2828
}
2929

30+
// IsReference returns true if the JSONSchema is a reference, false otherwise
3031
func (j *JSONSchema[Referenceable]) IsReference() bool {
3132
if j == nil || j.IsRight() {
3233
return false
@@ -35,6 +36,18 @@ func (j *JSONSchema[Referenceable]) IsReference() bool {
3536
return j.GetLeft().IsReference()
3637
}
3738

39+
// GetReference returns the reference of the JSONSchema if present, otherwise an empty string
40+
// This method is identical to GetRef() but was added to support the Resolvable interface
41+
func (j *JSONSchema[Referenceable]) GetReference() references.Reference {
42+
if j == nil {
43+
return ""
44+
}
45+
46+
return j.GetRef()
47+
}
48+
49+
// GetRef returns the reference of the JSONSchema if present, otherwise an empty string
50+
// This method is identical to GetReference() but was kept for backwards compatibility
3851
func (j *JSONSchema[Referenceable]) GetRef() references.Reference {
3952
if j == nil || j.IsRight() {
4053
return ""
@@ -43,6 +56,7 @@ func (j *JSONSchema[Referenceable]) GetRef() references.Reference {
4356
return j.GetLeft().GetRef()
4457
}
4558

59+
// GetAbsRef returns the absolute reference of the JSONSchema if present, otherwise an empty string
4660
func (j *JSONSchema[Referenceable]) GetAbsRef() references.Reference {
4761
if !j.IsReference() {
4862
return ""

jsonschema/oas3/schema_validate_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ additionalProperties: "invalid"
415415
`,
416416
wantErrs: []string{
417417
"schema field additionalProperties got string, want boolean or object",
418-
"schema expected object, got scalar",
418+
"schema.additionalProperties expected object, got scalar",
419419
},
420420
},
421421
{
@@ -442,7 +442,7 @@ items: "invalid"
442442
`,
443443
wantErrs: []string{
444444
"schema field items got string, want boolean or object",
445-
"schema expected object, got scalar",
445+
"schema.items expected object, got scalar",
446446
},
447447
},
448448
{

marshaller/unmarshaller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ func unmarshalModel(ctx context.Context, node *yaml.Node, structPtr any) ([]erro
383383
fieldVal := out.Field(fieldIndex)
384384

385385
if implementsInterface(fieldVal, nodeMutatorType) {
386-
fieldValidationErrs, err := unmarshalNode(ctx, modelTag, keyNode, valueNode, cachedField.Name, fieldVal)
386+
fieldValidationErrs, err := unmarshalNode(ctx, modelTag+"."+key, keyNode, valueNode, cachedField.Name, fieldVal)
387387
if err != nil {
388388
return err
389389
}

marshaller/unmarshalling_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ boolField: true
165165
intField: 42
166166
float64Field: 3.14
167167
`,
168-
wantErrs: []string{"[2:14] testPrimitiveModel expected scalar, got sequence"},
168+
wantErrs: []string{"[2:14] testPrimitiveModel.stringField expected scalar, got sequence"},
169169
},
170170
{
171171
name: "type mismatch - bool field gets string",
@@ -175,7 +175,7 @@ boolField: "not a bool"
175175
intField: 42
176176
float64Field: 3.14
177177
`,
178-
wantErrs: []string{"[3:12] testPrimitiveModel yaml: unmarshal errors:"},
178+
wantErrs: []string{"[3:12] testPrimitiveModel.boolField yaml: unmarshal errors:"},
179179
},
180180
{
181181
name: "type mismatch - int field gets string",
@@ -185,7 +185,7 @@ boolField: true
185185
intField: "not an int"
186186
float64Field: 3.14
187187
`,
188-
wantErrs: []string{"[4:11] testPrimitiveModel yaml: unmarshal errors:"},
188+
wantErrs: []string{"[4:11] testPrimitiveModel.intField yaml: unmarshal errors:"},
189189
},
190190
{
191191
name: "type mismatch - float field gets string",
@@ -195,7 +195,7 @@ boolField: true
195195
intField: 42
196196
float64Field: "not a float"
197197
`,
198-
wantErrs: []string{"[5:15] testPrimitiveModel yaml: unmarshal errors:"},
198+
wantErrs: []string{"[5:15] testPrimitiveModel.float64Field yaml: unmarshal errors:"},
199199
},
200200
{
201201
name: "multiple validation errors",
@@ -206,7 +206,7 @@ intField: "not an int"
206206
wantErrs: []string{
207207
"[2:1] testPrimitiveModel field stringField is missing",
208208
"[2:1] testPrimitiveModel field float64Field is missing",
209-
"[2:12] testPrimitiveModel yaml: unmarshal errors:",
209+
"[2:12] testPrimitiveModel.boolField yaml: unmarshal errors:",
210210
},
211211
},
212212
}
@@ -379,7 +379,7 @@ nestedModelValue:
379379
nestedModel:
380380
- "this should be an object"
381381
`,
382-
wantErrs: []string{"[8:3] testComplexModel expected object, got sequence"},
382+
wantErrs: []string{"[8:3] testComplexModel.nestedModel expected object, got sequence"},
383383
},
384384
{
385385
name: "type mismatch - array field gets object",
@@ -392,7 +392,7 @@ nestedModelValue:
392392
arrayField:
393393
key: "this should be an array"
394394
`,
395-
wantErrs: []string{"[8:3] testComplexModel expected sequence, got object"},
395+
wantErrs: []string{"[8:3] testComplexModel.arrayField expected sequence, got object"},
396396
},
397397
{
398398
name: "deeply nested validation error",

openapi/join.go

Lines changed: 191 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,12 @@ func joinSingleDocument(ctx context.Context, result, doc *OpenAPI, docPath strin
206206
return fmt.Errorf("failed to update references in document: %w", err)
207207
}
208208

209+
// Collect existing operationIds to detect conflicts
210+
usedOperationIds := collectOperationIds(result)
211+
212+
// Resolve operationId conflicts in the source document
213+
resolveOperationIdConflicts(doc, usedOperationIds, docPath)
214+
209215
// Join paths with conflict resolution
210216
joinPaths(result, doc, docPath, usedPathNames)
211217

@@ -232,19 +238,94 @@ func joinPaths(result, src *OpenAPI, srcPath string, usedPathNames map[string]bo
232238
result.Paths = NewPaths()
233239
}
234240

235-
for path, pathItem := range src.Paths.All() {
241+
for path, srcPathItem := range src.Paths.All() {
236242
if !usedPathNames[path] {
237243
// No conflict, add directly
238-
result.Paths.Set(path, pathItem)
244+
result.Paths.Set(path, srcPathItem)
239245
usedPathNames[path] = true
240246
} else {
241-
// Conflict detected, create new path with fragment
242-
newPath := generateConflictPath(path, srcPath)
243-
result.Paths.Set(newPath, pathItem)
244-
usedPathNames[newPath] = true
247+
// Path exists, need to check for operation conflicts
248+
existingPathItem, exists := result.Paths.Get(path)
249+
if !exists || existingPathItem == nil || existingPathItem.Object == nil || srcPathItem == nil || srcPathItem.Object == nil {
250+
// Safety check - if we can't access the operations, create conflict path
251+
newPath := generateConflictPath(path, srcPath)
252+
result.Paths.Set(newPath, srcPathItem)
253+
usedPathNames[newPath] = true
254+
continue
255+
}
256+
257+
// Try to merge operations from source into existing path
258+
conflictingOperations := mergePathItemOperations(existingPathItem.Object, srcPathItem.Object)
259+
260+
if len(conflictingOperations) == 0 {
261+
// No conflicts, all operations merged successfully - existing path already updated
262+
continue
263+
} else {
264+
// Some operations had conflicts, create new path for conflicting operations only
265+
conflictPathItem := createPathItemWithOperations(conflictingOperations)
266+
267+
newPath := generateConflictPath(path, srcPath)
268+
result.Paths.Set(newPath, &ReferencedPathItem{Object: conflictPathItem})
269+
usedPathNames[newPath] = true
270+
}
271+
}
272+
}
273+
}
274+
275+
// ConflictingOperation represents an operation that conflicts with an existing one
276+
type ConflictingOperation struct {
277+
Method HTTPMethod
278+
Operation *Operation
279+
}
280+
281+
// mergePathItemOperations attempts to merge operations from srcPathItem into existingPathItem
282+
// Returns conflicting operations that couldn't be merged
283+
func mergePathItemOperations(existingPathItem, srcPathItem *PathItem) []ConflictingOperation {
284+
conflictingOperations := []ConflictingOperation{}
285+
286+
// Iterate through all operations in the source PathItem
287+
for method, srcOp := range srcPathItem.All() {
288+
if srcOp == nil {
289+
continue
290+
}
291+
292+
// Check if existing PathItem has this method
293+
existingOp := existingPathItem.GetOperation(method)
294+
295+
if existingOp == nil {
296+
// No conflict, add the operation to existing PathItem
297+
existingPathItem.Set(method, srcOp)
298+
} else {
299+
// Both have this method, check if operations are identical
300+
srcHash := hashing.Hash(srcOp)
301+
existingHash := hashing.Hash(existingOp)
302+
303+
if srcHash == existingHash {
304+
// Identical operations, keep existing (deduplicate)
305+
continue
306+
} else {
307+
// Different operations, this is a conflict
308+
conflictingOperations = append(conflictingOperations, ConflictingOperation{
309+
Method: method,
310+
Operation: srcOp,
311+
})
312+
}
245313
}
246314
}
247315

316+
return conflictingOperations
317+
}
318+
319+
// createPathItemWithOperations creates a new PathItem containing only the specified conflicting operations
320+
func createPathItemWithOperations(conflictingOps []ConflictingOperation) *PathItem {
321+
pathItem := NewPathItem()
322+
323+
// Add each conflicting operation with its original method
324+
for _, conflictOp := range conflictingOps {
325+
pathItem.Set(conflictOp.Method, conflictOp.Operation)
326+
}
327+
328+
return pathItem
248329
}
249330

250331
// generateConflictPath creates a new path with a fragment containing the file name
@@ -738,6 +819,110 @@ func joinTags(result, src *OpenAPI) {
738819
}
739820
}
740821

822+
// collectOperationIds collects all operationIds from the given OpenAPI document
823+
func collectOperationIds(doc *OpenAPI) map[string]bool {
824+
usedOperationIds := make(map[string]bool)
825+
826+
if doc.Paths != nil {
827+
for _, pathItem := range doc.Paths.All() {
828+
if pathItem == nil || pathItem.Object == nil {
829+
continue
830+
}
831+
832+
// Check all operations in the path item
833+
for _, operation := range pathItem.Object.All() {
834+
if operation != nil && operation.OperationID != nil && *operation.OperationID != "" {
835+
usedOperationIds[*operation.OperationID] = true
836+
}
837+
}
838+
}
839+
}
840+
841+
// Also check webhooks
842+
if doc.Webhooks != nil {
843+
for _, webhook := range doc.Webhooks.All() {
844+
if webhook == nil || webhook.Object == nil {
845+
continue
846+
}
847+
848+
for _, operation := range webhook.Object.All() {
849+
if operation != nil && operation.OperationID != nil && *operation.OperationID != "" {
850+
usedOperationIds[*operation.OperationID] = true
851+
}
852+
}
853+
}
854+
}
855+
856+
return usedOperationIds
857+
}
858+
859+
// resolveOperationIdConflicts resolves operationId conflicts by adding #docname suffix
860+
func resolveOperationIdConflicts(doc *OpenAPI, usedOperationIds map[string]bool, docPath string) {
861+
// Extract document name from path for suffix
862+
docName := generateDocumentName(docPath)
863+
864+
if doc.Paths != nil {
865+
for _, pathItem := range doc.Paths.All() {
866+
if pathItem == nil || pathItem.Object == nil {
867+
continue
868+
}
869+
870+
// Check all operations in the path item
871+
for _, operation := range pathItem.Object.All() {
872+
if operation != nil && operation.OperationID != nil && *operation.OperationID != "" {
873+
originalId := *operation.OperationID
874+
if usedOperationIds[originalId] {
875+
// Conflict detected, add suffix
876+
newId := originalId + "#" + docName
877+
operation.OperationID = &newId
878+
} else {
879+
// No conflict, mark as used
880+
usedOperationIds[originalId] = true
881+
}
882+
}
883+
}
884+
}
885+
}
886+
887+
// Also handle webhooks
888+
if doc.Webhooks != nil {
889+
for _, webhook := range doc.Webhooks.All() {
890+
if webhook == nil || webhook.Object == nil {
891+
continue
892+
}
893+
894+
for _, operation := range webhook.Object.All() {
895+
if operation != nil && operation.OperationID != nil && *operation.OperationID != "" {
896+
originalId := *operation.OperationID
897+
if usedOperationIds[originalId] {
898+
// Conflict detected, add suffix
899+
newId := originalId + "#" + docName
900+
operation.OperationID = &newId
901+
} else {
902+
// No conflict, mark as used
903+
usedOperationIds[originalId] = true
904+
}
905+
}
906+
}
907+
}
908+
}
909+
}
910+
911+
// generateDocumentName extracts a clean document name from the file path
912+
func generateDocumentName(filePath string) string {
913+
// Extract filename without extension
914+
baseName := filepath.Base(filePath)
915+
ext := filepath.Ext(baseName)
916+
if ext != "" {
917+
baseName = baseName[:len(baseName)-len(ext)]
918+
}
919+
920+
// Clean the filename to make it safe
921+
safeFileName := regexp.MustCompile(`[^a-zA-Z0-9_-]`).ReplaceAllString(baseName, "_")
922+
923+
return safeFileName
924+
}
925+
741926
// joinServersAndSecurity handles smart conflict resolution for servers and security
742927
func joinServersAndSecurity(result, src *OpenAPI) {
743928
// Check if servers are identical (by hash)

openapi/join_test.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,19 @@ func TestJoin_NoFilePath_Success(t *testing.T) {
257257
err = openapi.Join(ctx, mainDoc, documents, opts)
258258
require.NoError(t, err)
259259

260-
// Verify original /users path exists
260+
// Verify original /users path exists and contains both operations
261261
assert.True(t, mainDoc.Paths.Has("/users"))
262+
usersPath, exists := mainDoc.Paths.Get("/users")
263+
require.True(t, exists, "Users path should exist")
264+
require.NotNil(t, usersPath)
265+
require.NotNil(t, usersPath.Object)
262266

263-
// Verify conflicting path uses fallback name
264-
assert.True(t, mainDoc.Paths.Has("/users#document_0"))
267+
// Should have both GET (from main) and POST (from second)
268+
assert.NotNil(t, usersPath.Object.Get, "Should have GET operation from main document")
269+
assert.NotNil(t, usersPath.Object.Post, "Should have POST operation from second document")
270+
271+
// Should NOT have a duplicated path since methods are different
272+
assert.False(t, mainDoc.Paths.Has("/users#document_0"), "Should not create duplicate path for different methods")
265273
}
266274

267275
func TestJoin_ServersSecurityConflicts_Success(t *testing.T) {

openapi/openapi_examples_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ func Example_validating() {
214214
fmt.Println("Document is valid!")
215215
}
216216
// Output: Validation error: [3:3] info field version is missing
217-
// Validation error: [18:30] response expected object, got scalar
217+
// Validation error: [18:30] response.content expected object, got scalar
218218
// Validation error: [31:25] schema field properties.name.type value must be one of 'array', 'boolean', 'integer', 'null', 'number', 'object', 'string'
219219
// Validation error: [31:25] schema field properties.name.type got string, want array
220220
// Additional validation error: [31:25] schema field properties.name.type value must be one of 'array', 'boolean', 'integer', 'null', 'number', 'object', 'string'

openapi/responses.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func (r *Responses) Validate(ctx context.Context, opts ...validation.Option) []e
105105
errs = append(errs, r.Default.Validate(ctx, opts...)...)
106106
}
107107

108-
if r.Len() == 0 {
108+
if r.Len() == 0 && r.Default == nil {
109109
errs = append(errs, validation.NewValidationError(validation.NewValueValidationError("responses must have at least one response code"), core.RootNode))
110110
}
111111

0 commit comments

Comments
 (0)