Skip to content

Commit bbff202

Browse files
TristanSpeakEasyCopilotreefbarman
authored
fix: resolve parent directory references in OpenAPI bundle to meaningful component names (#51)
Co-authored-by: Copilot <[email protected]> Co-authored-by: Tristan Cartledge <[email protected]>
1 parent e3920da commit bbff202

File tree

11 files changed

+692
-29
lines changed

11 files changed

+692
-29
lines changed

.github/workflows/commits.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@ on:
1010
- ready_for_review
1111
jobs:
1212
build:
13-
name: Conventional Commits
13+
name: Conventional PR Title
1414
runs-on: ubuntu-latest
1515
steps:
16-
- uses: actions/checkout@v5
17-
- uses: webiny/[email protected]
1816
- uses: amannn/[email protected]
1917
env:
2018
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jsonschema/oas3/schema_validate_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,14 @@ properties:
334334
- type: number
335335
- type: boolean
336336
required: ["user"]
337+
`,
338+
},
339+
{
340+
name: "valid schema with $ref and additional properties (OpenAPI 3.1)",
341+
yml: `
342+
$ref: "#/components/schemas/User"
343+
required: ["name", "email"]
344+
description: "User schema with additional validation requirements"
337345
`,
338346
},
339347
}
@@ -474,6 +482,15 @@ oneOf: "invalid"
474482
`,
475483
wantErrs: []string{"schema field oneOf got string, want array"},
476484
},
485+
{
486+
name: "$ref with additional properties not allowed in OpenAPI 3.0",
487+
yml: `
488+
$schema: "https://spec.openapis.org/oas/3.0/dialect/2024-10-18"
489+
$ref: "#/components/schemas/User"
490+
required: ["name", "email"]
491+
`,
492+
wantErrs: []string{"additional properties '$ref' not allowed"},
493+
},
477494
}
478495

479496
for _, tt := range tests {

marshaller/model.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,21 @@ func (m *Model[T]) GetRootNode() *yaml.Node {
9898
}
9999

100100
func (m *Model[T]) GetRootNodeLine() int {
101+
if m == nil {
102+
return -1
103+
}
104+
101105
if rootNode := m.GetRootNode(); rootNode != nil {
102106
return rootNode.Line
103107
}
104108
return -1
105109
}
106110

107111
func (m *Model[T]) GetRootNodeColumn() int {
112+
if m == nil {
113+
return -1
114+
}
115+
108116
if rootNode := m.GetRootNode(); rootNode != nil {
109117
return rootNode.Column
110118
}
@@ -161,7 +169,7 @@ func (m *Model[T]) SetCoreAny(core any) {
161169
}
162170

163171
func (m *Model[T]) GetCachedReferencedObject(key string) (any, bool) {
164-
if m.objectCache == nil {
172+
if m == nil || m.objectCache == nil {
165173
return nil, false
166174
}
167175
return m.objectCache.Load(key)
@@ -172,7 +180,7 @@ func (m *Model[T]) StoreReferencedObjectInCache(key string, obj any) {
172180
}
173181

174182
func (m *Model[T]) GetCachedReferenceDocument(key string) ([]byte, bool) {
175-
if m.documentCache == nil {
183+
if m == nil || m.documentCache == nil {
176184
return nil, false
177185
}
178186
value, ok := m.documentCache.Load(key)

openapi/bundle.go

Lines changed: 124 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package openapi
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"path/filepath"
78
"regexp"
@@ -253,7 +254,10 @@ func bundleSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceabl
253254
resolvedHash := hashing.Hash(resolvedRefSchema)
254255

255256
// Generate component name with smart conflict resolution
256-
componentName := generateComponentNameWithHashConflictResolution(ref, namingStrategy, componentStorage.componentNames, componentStorage.schemaHashes, resolvedHash)
257+
componentName, err := generateComponentNameWithHashConflictResolution(ref, namingStrategy, componentStorage.componentNames, componentStorage.schemaHashes, resolvedHash, opts.TargetLocation)
258+
if err != nil {
259+
return fmt.Errorf("failed to generate component name for %s: %w", ref, err)
260+
}
257261

258262
// Store the mapping
259263
componentStorage.externalRefs[ref] = componentName
@@ -366,7 +370,10 @@ func bundleGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreM
366370
}
367371

368372
// Generate component name
369-
componentName := generateComponentName(refStr, namingStrategy, componentStorage.componentNames)
373+
componentName, err := generateComponentName(refStr, namingStrategy, componentStorage.componentNames, opts.TargetLocation)
374+
if err != nil {
375+
return fmt.Errorf("failed to generate component name for %s: %w", refStr, err)
376+
}
370377
componentStorage.componentNames[componentName] = true
371378

372379
// Store the mapping
@@ -405,19 +412,19 @@ func bundleGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreM
405412
}
406413

407414
// generateComponentName creates a new component name based on the reference and naming strategy
408-
func generateComponentName(ref string, strategy BundleNamingStrategy, usedNames map[string]bool) string {
415+
func generateComponentName(ref string, strategy BundleNamingStrategy, usedNames map[string]bool, targetLocation string) (string, error) {
409416
switch strategy {
410417
case BundleNamingFilePath:
411-
return generateFilePathBasedNameWithConflictResolution(ref, usedNames)
418+
return generateFilePathBasedNameWithConflictResolution(ref, usedNames, targetLocation)
412419
case BundleNamingCounter:
413-
return generateCounterBasedName(ref, usedNames)
420+
return generateCounterBasedName(ref, usedNames), nil
414421
default:
415-
return generateCounterBasedName(ref, usedNames)
422+
return generateCounterBasedName(ref, usedNames), nil
416423
}
417424
}
418425

419426
// generateComponentNameWithHashConflictResolution creates a component name with smart conflict resolution based on content hashes
420-
func generateComponentNameWithHashConflictResolution(ref string, strategy BundleNamingStrategy, usedNames map[string]bool, schemaHashes map[string]string, resolvedHash string) string {
427+
func generateComponentNameWithHashConflictResolution(ref string, strategy BundleNamingStrategy, usedNames map[string]bool, schemaHashes map[string]string, resolvedHash string, targetLocation string) (string, error) {
421428
// Parse the reference to extract the simple name
422429
parts := strings.Split(ref, "#")
423430
if len(parts) == 0 {
@@ -454,24 +461,24 @@ func generateComponentNameWithHashConflictResolution(ref string, strategy Bundle
454461
if existingHash, exists := schemaHashes[simpleName]; exists {
455462
if existingHash == resolvedHash {
456463
// Same content, reuse existing schema
457-
return simpleName
464+
return simpleName, nil
458465
}
459466
// Different content with same name - need conflict resolution
460467
// Fall back to the configured naming strategy for conflict resolution
461-
return generateComponentName(ref, strategy, usedNames)
468+
return generateComponentName(ref, strategy, usedNames, targetLocation)
462469
}
463470

464471
// No conflict, use simple name
465-
return simpleName
472+
return simpleName, nil
466473
}
467474

468475
// generateFilePathBasedNameWithConflictResolution tries to use simple names first, falling back to file-path-based names for conflicts
469-
func generateFilePathBasedNameWithConflictResolution(ref string, usedNames map[string]bool) string {
476+
func generateFilePathBasedNameWithConflictResolution(ref string, usedNames map[string]bool, targetLocation string) (string, error) {
470477
// Parse the reference to extract file path and fragment
471478
parts := strings.Split(ref, "#")
472479
if len(parts) == 0 {
473480
// This should never happen as strings.Split never returns nil or empty slice
474-
return "unknown"
481+
return "unknown", nil
475482
}
476483
fragment := ""
477484
if len(parts) > 1 {
@@ -502,20 +509,20 @@ func generateFilePathBasedNameWithConflictResolution(ref string, usedNames map[s
502509

503510
// Try simple name first
504511
if !usedNames[simpleName] {
505-
return simpleName
512+
return simpleName, nil
506513
}
507514

508515
// If there's a conflict, fall back to file-path-based naming
509-
return generateFilePathBasedName(ref, usedNames)
516+
return generateFilePathBasedName(ref, usedNames, targetLocation)
510517
}
511518

512519
// generateFilePathBasedName creates names like "some_path_external_yaml~User" or "some_path_external_yaml" for top-level refs
513-
func generateFilePathBasedName(ref string, usedNames map[string]bool) string {
520+
func generateFilePathBasedName(ref string, usedNames map[string]bool, targetLocation string) (string, error) {
514521
// Parse the reference to extract file path and fragment
515522
parts := strings.Split(ref, "#")
516523
if len(parts) == 0 {
517524
// This should never happen as strings.Split never returns nil or empty slice
518-
return "unknown"
525+
return "unknown", nil
519526
}
520527
filePath := parts[0]
521528
fragment := ""
@@ -530,6 +537,14 @@ func generateFilePathBasedName(ref string, usedNames map[string]bool) string {
530537
// Remove leading "./" if present
531538
cleanPath = strings.TrimPrefix(cleanPath, "./")
532539

540+
// Handle parent directory references more elegantly
541+
// Instead of converting "../" to "___", we'll normalize the path
542+
normalizedPath, err := normalizePathForComponentName(cleanPath, targetLocation)
543+
if err != nil {
544+
return "", fmt.Errorf("failed to normalize path %s: %w", cleanPath, err)
545+
}
546+
cleanPath = normalizedPath
547+
533548
// Replace extension dot with underscore to keep it but make it safe
534549
ext := filepath.Ext(cleanPath)
535550
if ext != "" {
@@ -559,7 +574,99 @@ func generateFilePathBasedName(ref string, usedNames map[string]bool) string {
559574
counter++
560575
}
561576

562-
return componentName
577+
return componentName, nil
578+
}
579+
580+
// normalizePathForComponentName normalizes a file path to create a more readable component name
581+
// by resolving relative paths to their actual directory names using absolute path resolution
582+
func normalizePathForComponentName(path, targetLocation string) (string, error) {
583+
if targetLocation == "" {
584+
return "", errors.New("target location cannot be empty for path normalization")
585+
}
586+
587+
// Get the directory of the target location
588+
targetDir := filepath.Dir(targetLocation)
589+
590+
// Resolve the relative path against the target directory to get absolute path
591+
resolvedAbsPath, err := filepath.Abs(filepath.Join(targetDir, path))
592+
if err != nil {
593+
return "", fmt.Errorf("failed to resolve relative path: %w", err)
594+
}
595+
596+
// Split the original relative path to find where the real path starts (after all the ../)
597+
// Handle both Unix and Windows path separators
598+
normalizedPath := strings.ReplaceAll(path, "\\", "/")
599+
pathParts := strings.Split(normalizedPath, "/")
600+
601+
// Count parent directory navigations and find the start of the real path
602+
parentCount := 0
603+
realPathStart := len(pathParts) // Default to end if no real path found
604+
foundRealPath := false
605+
606+
for i, part := range pathParts {
607+
if foundRealPath {
608+
break
609+
}
610+
611+
switch part {
612+
case "..":
613+
parentCount++
614+
case ".":
615+
// Skip current directory references
616+
continue
617+
case "":
618+
// Skip empty parts
619+
continue
620+
default:
621+
// Found the start of the real path
622+
realPathStart = i
623+
foundRealPath = true
624+
}
625+
}
626+
627+
// Get the real path parts (everything after the ../ navigation)
628+
var realPathParts []string
629+
if realPathStart < len(pathParts) {
630+
realPathParts = pathParts[realPathStart:]
631+
}
632+
633+
// Use the absolute path to get the meaningful directory structure
634+
// Split the absolute path and take the last meaningful parts
635+
absParts := strings.Split(strings.ReplaceAll(resolvedAbsPath, "\\", "/"), "/")
636+
637+
// We want to include the directory we land on after navigation plus the real path
638+
// For "../../../other/api.yaml" from "openapi/a/b/c/spec.yaml", we want "openapi/other/api.yaml"
639+
// So we need: landing directory (openapi) + real path parts (other/api.yaml)
640+
641+
var resultParts []string
642+
643+
if parentCount > 0 {
644+
// Find the landing directory after going up parentCount levels
645+
// We need at least parentCount + len(realPathParts) parts in the absolute path
646+
requiredParts := 1 + len(realPathParts) // 1 for landing directory + real path parts
647+
648+
if len(absParts) < requiredParts {
649+
return "", fmt.Errorf("not enough path components in resolved absolute path: got %d, need at least %d", len(absParts), requiredParts)
650+
}
651+
652+
// Take the landing directory (the directory we end up in after going up)
653+
landingDirIndex := len(absParts) - len(realPathParts) - 1
654+
if landingDirIndex >= 0 && landingDirIndex < len(absParts) {
655+
landingDir := absParts[landingDirIndex]
656+
resultParts = append(resultParts, landingDir)
657+
}
658+
}
659+
660+
// Add the real path parts
661+
resultParts = append(resultParts, realPathParts...)
662+
663+
// Join and clean up the result
664+
result := strings.Join(resultParts, "/")
665+
666+
// Remove leading "./" if present
667+
result = strings.TrimPrefix(result, "./")
668+
669+
return result, nil
563670
}
564671

565672
// generateCounterBasedName creates names like "User_1", "User_2" for conflicts

openapi/marshalling.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66

77
"github.com/speakeasy-api/openapi/marshaller"
8+
"github.com/speakeasy-api/openapi/openapi/core"
89
"github.com/speakeasy-api/openapi/validation"
910
)
1011

@@ -57,7 +58,7 @@ func Marshal(ctx context.Context, openapi *OpenAPI, w io.Writer) error {
5758

5859
// Sync will sync the high-level model to the core model.
5960
// This is useful when creating or mutating a high-level model and wanting access to the yaml nodes that back it.
60-
func Sync(ctx context.Context, model marshaller.Marshallable[OpenAPI]) error {
61+
func Sync(ctx context.Context, model marshaller.Marshallable[core.OpenAPI]) error {
6162
_, err := marshaller.SyncValue(ctx, model, model.GetCore(), model.GetRootNode(), false)
6263
return err
6364
}

0 commit comments

Comments
 (0)