diff --git a/.mise.toml b/.mise.toml index 29f9618..1dccfed 100644 --- a/.mise.toml +++ b/.mise.toml @@ -7,7 +7,8 @@ gotestsum = "latest" description = "Create VSCode symlinks for tools not automatically handled by mise-vscode" run = [ "mkdir -p .vscode/mise-tools", - "ln -sf $(mise exec golangci-lint@2.1.1 -- which golangci-lint) .vscode/mise-tools/golangci-lint", + "ln -sf $(mise exec -- which golangci-lint-v2) $(dirname $(mise exec -- which golangci-lint-v2))/golangci-lint || true", + "ln -sf $(mise exec -- which golangci-lint) .vscode/mise-tools/golangci-lint", ] [hooks] diff --git a/internal/utils/references.go b/internal/utils/references.go index e4bc9aa..a8e8a22 100644 --- a/internal/utils/references.go +++ b/internal/utils/references.go @@ -148,7 +148,8 @@ func (rc *ReferenceClassification) JoinWith(relative string) (string, error) { } if rc.IsFile { - return rc.joinFilePath(relative) + joined := rc.joinFilePath(relative) + return joined, nil } // If base is a fragment, treat relative as the new reference @@ -157,7 +158,8 @@ func (rc *ReferenceClassification) JoinWith(relative string) (string, error) { } // Fallback: treat as file path - return rc.joinFilePath(relative) + joined := rc.joinFilePath(relative) + return joined, nil } // joinURL joins this URL reference with a relative reference using the cached ParsedURL @@ -185,11 +187,11 @@ func (rc *ReferenceClassification) joinURL(relative string) (string, error) { } // joinFilePath joins this file path reference with a relative path using cross-platform path handling -func (rc *ReferenceClassification) joinFilePath(relative string) (string, error) { +func (rc *ReferenceClassification) joinFilePath(relative string) string { // If relative path is absolute, return it as-is // Check for both OS-specific absolute paths and Unix-style absolute paths (for cross-platform compatibility) if filepath.IsAbs(relative) || strings.HasPrefix(relative, "/") || rc.isWindowsAbsolutePath(relative) { - return relative, nil + return relative } // Determine the path separator style from the original path @@ -217,7 +219,7 @@ func (rc *ReferenceClassification) joinFilePath(relative string) (string, error) joined = strings.ReplaceAll(joined, "\\", "/") } - return joined, nil + return joined } // getWindowsDir extracts the directory part from a Windows-style path diff --git a/internal/utils/references_windows_test.go b/internal/utils/references_windows_test.go index 47fc15e..eb6bfec 100644 --- a/internal/utils/references_windows_test.go +++ b/internal/utils/references_windows_test.go @@ -10,6 +10,8 @@ import ( ) func TestWindowsPathClassification_Success(t *testing.T) { + t.Parallel() + tests := []struct { name string windowsPath string @@ -46,6 +48,7 @@ func TestWindowsPathClassification_Success(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() classification, err := ClassifyReference(tt.windowsPath) require.NoError(t, err) require.NotNil(t, classification) @@ -60,6 +63,8 @@ func TestWindowsPathClassification_Success(t *testing.T) { } func TestWindowsPathJoining_Success(t *testing.T) { + t.Parallel() + tests := []struct { name string base string @@ -87,8 +92,8 @@ func TestWindowsPathJoining_Success(t *testing.T) { { name: "windows path with absolute relative path", base: "C:\\path\\to\\schemas\\user.json", - relative: "D:\\other\\path\\schema.json", - expected: "D:\\other\\path\\schema.json", + relative: "D:\\some\\path\\schema.json", + expected: "D:\\some\\path\\schema.json", }, { name: "windows path with fragment", @@ -100,6 +105,7 @@ func TestWindowsPathJoining_Success(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() classification, err := ClassifyReference(tt.base) require.NoError(t, err) require.NotNil(t, classification) @@ -113,6 +119,8 @@ func TestWindowsPathJoining_Success(t *testing.T) { } func TestWindowsPathJoinReference_Success(t *testing.T) { + t.Parallel() + tests := []struct { name string base string @@ -135,6 +143,7 @@ func TestWindowsPathJoinReference_Success(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result, err := JoinReference(tt.base, tt.relative) require.NoError(t, err) assert.Equal(t, tt.expected, result) diff --git a/openapi/bundle.go b/openapi/bundle.go index 41f2a78..0f51a76 100644 --- a/openapi/bundle.go +++ b/openapi/bundle.go @@ -119,12 +119,25 @@ func Bundle(ctx context.Context, doc *OpenAPI, opts BundleOptions) error { return nil } + // Make target location absolute at the entry point + targetLocation := opts.ResolveOptions.TargetLocation + if targetLocation != "" && !filepath.IsAbs(targetLocation) { + if absTarget, err := filepath.Abs(targetLocation); err == nil { + targetLocation = absTarget + opts.ResolveOptions.TargetLocation = absTarget + } + // Error getting absolute path is not fatal - we continue with the relative path + // This allows processing to proceed even if there are issues with path resolution + } + componentStorage := &componentStorage{ schemaStorage: sequencedmap.New[string, *oas3.JSONSchema[oas3.Referenceable]](), referenceStorage: sequencedmap.New[string, *sequencedmap.Map[string, any]](), - externalRefs: make(map[string]string), + refs: make(map[string]string), componentNames: make(map[string]bool), schemaHashes: make(map[string]string), + schemaLocations: make(map[string]string), + rootLocation: targetLocation, } // Initialize existing component names and hashes to avoid conflicts @@ -137,7 +150,7 @@ func Bundle(ctx context.Context, doc *OpenAPI, opts BundleOptions) error { } } - if err := bundleObject(ctx, doc, opts.NamingStrategy, "", opts.ResolveOptions, componentStorage); err != nil { + if err := bundleObject(ctx, doc, opts.NamingStrategy, opts.ResolveOptions, componentStorage); err != nil { return err } @@ -162,43 +175,45 @@ func Bundle(ctx context.Context, doc *OpenAPI, opts BundleOptions) error { type componentStorage struct { schemaStorage *sequencedmap.Map[string, *oas3.JSONSchema[oas3.Referenceable]] referenceStorage *sequencedmap.Map[string, *sequencedmap.Map[string, any]] - externalRefs map[string]string // original ref -> new component name + refs map[string]string // absolute ref -> component name componentNames map[string]bool // track used names to avoid conflicts schemaHashes map[string]string // component name -> hash for conflict detection + schemaLocations map[string]string // component name -> absolute source location (for rewriting refs) + rootLocation string // absolute path to root document for relative path calculation } -func bundleObject[T any](ctx context.Context, obj *T, namingStrategy BundleNamingStrategy, parentLocation string, opts ResolveOptions, componentStorage *componentStorage) error { +func bundleObject[T any](ctx context.Context, obj *T, namingStrategy BundleNamingStrategy, opts ResolveOptions, componentStorage *componentStorage) error { for item := range Walk(ctx, obj) { err := item.Match(Matcher{ Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { - return bundleSchema(ctx, schema, namingStrategy, parentLocation, opts, componentStorage) + return bundleSchema(ctx, schema, namingStrategy, opts, componentStorage) }, ReferencedPathItem: func(ref *ReferencedPathItem) error { - return bundleGenericReference(ctx, ref, namingStrategy, parentLocation, opts, componentStorage, "pathItems") + return bundleGenericReference(ctx, ref, namingStrategy, opts, componentStorage, "pathItems") }, ReferencedParameter: func(ref *ReferencedParameter) error { - return bundleGenericReference(ctx, ref, namingStrategy, parentLocation, opts, componentStorage, "parameters") + return bundleGenericReference(ctx, ref, namingStrategy, opts, componentStorage, "parameters") }, ReferencedExample: func(ref *ReferencedExample) error { - return bundleGenericReference(ctx, ref, namingStrategy, parentLocation, opts, componentStorage, "examples") + return bundleGenericReference(ctx, ref, namingStrategy, opts, componentStorage, "examples") }, ReferencedRequestBody: func(ref *ReferencedRequestBody) error { - return bundleGenericReference(ctx, ref, namingStrategy, parentLocation, opts, componentStorage, "requestBodies") + return bundleGenericReference(ctx, ref, namingStrategy, opts, componentStorage, "requestBodies") }, ReferencedResponse: func(ref *ReferencedResponse) error { - return bundleGenericReference(ctx, ref, namingStrategy, parentLocation, opts, componentStorage, "responses") + return bundleGenericReference(ctx, ref, namingStrategy, opts, componentStorage, "responses") }, ReferencedHeader: func(ref *ReferencedHeader) error { - return bundleGenericReference(ctx, ref, namingStrategy, parentLocation, opts, componentStorage, "headers") + return bundleGenericReference(ctx, ref, namingStrategy, opts, componentStorage, "headers") }, ReferencedCallback: func(ref *ReferencedCallback) error { - return bundleGenericReference(ctx, ref, namingStrategy, parentLocation, opts, componentStorage, "callbacks") + return bundleGenericReference(ctx, ref, namingStrategy, opts, componentStorage, "callbacks") }, ReferencedLink: func(ref *ReferencedLink) error { - return bundleGenericReference(ctx, ref, namingStrategy, parentLocation, opts, componentStorage, "links") + return bundleGenericReference(ctx, ref, namingStrategy, opts, componentStorage, "links") }, ReferencedSecurityScheme: func(ref *ReferencedSecurityScheme) error { - return bundleGenericReference(ctx, ref, namingStrategy, parentLocation, opts, componentStorage, "securitySchemes") + return bundleGenericReference(ctx, ref, namingStrategy, opts, componentStorage, "securitySchemes") }, }) if err != nil { @@ -210,23 +225,23 @@ func bundleObject[T any](ctx context.Context, obj *T, namingStrategy BundleNamin } // bundleSchema handles bundling of JSON schemas with external references -func bundleSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceable], namingStrategy BundleNamingStrategy, parentLocation string, opts ResolveOptions, componentStorage *componentStorage) error { +func bundleSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceable], namingStrategy BundleNamingStrategy, opts ResolveOptions, componentStorage *componentStorage) error { if !schema.IsReference() { return nil } - ref, classification := handleReference(schema.GetRef(), parentLocation, opts.TargetLocation) + ref, classification := handleReference(schema.GetRef(), opts.TargetLocation) if classification == nil { return nil // Invalid reference, skip } - // If it's a fragment reference, check if it's pointing to a different document - if classification.IsFragment { - return nil // Internal reference within the root document, skip + // Check if this is an internal reference to the root document + if isInternalReference(ref, componentStorage.rootLocation) { + return nil } // Check if we've already processed this reference - if _, exists := componentStorage.externalRefs[ref]; exists { + if _, exists := componentStorage.refs[ref]; exists { return nil } @@ -254,13 +269,13 @@ func bundleSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceabl resolvedHash := hashing.Hash(resolvedRefSchema) // Generate component name with smart conflict resolution - componentName, err := generateComponentNameWithHashConflictResolution(ref, namingStrategy, componentStorage.componentNames, componentStorage.schemaHashes, resolvedHash, opts.TargetLocation) + componentName, err := generateComponentNameWithHashConflictResolution(ref, namingStrategy, componentStorage.componentNames, componentStorage.schemaHashes, resolvedHash, componentStorage.rootLocation) if err != nil { return fmt.Errorf("failed to generate component name for %s: %w", ref, err) } // Store the mapping - componentStorage.externalRefs[ref] = componentName + componentStorage.refs[ref] = componentName // Only add to componentSchemas if it's a new schema (not a duplicate) if _, exists := componentStorage.schemaHashes[componentName]; !exists { @@ -268,9 +283,12 @@ func bundleSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceabl componentStorage.schemaHashes[componentName] = resolvedHash componentStorage.schemaStorage.Set(componentName, resolvedRefSchema) + // Store the source location for this schema for later reference rewriting + componentStorage.schemaLocations[componentName] = ref + targetDocInfo := schema.GetReferenceResolutionInfo() - if err := bundleObject(ctx, resolvedRefSchema, namingStrategy, opts.TargetLocation, references.ResolveOptions{ + if err := bundleObject(ctx, resolvedRefSchema, namingStrategy, references.ResolveOptions{ RootDocument: opts.RootDocument, TargetDocument: targetDocInfo.ResolvedDocument, TargetLocation: targetDocInfo.AbsoluteReference, @@ -285,8 +303,11 @@ func bundleSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceabl // rewriteRefsInBundledSchemas rewrites references within bundled schemas to point to their new component locations func rewriteRefsInBundledSchemas(ctx context.Context, componentStorage *componentStorage) error { // Walk through each bundled schema and rewrite internal references - for _, schema := range componentStorage.schemaStorage.All() { - err := rewriteRefsInSchema(ctx, schema, componentStorage) + for componentName, schema := range componentStorage.schemaStorage.All() { + // Get the source location for this schema + sourceLocation := componentStorage.schemaLocations[componentName] + + err := rewriteRefsInSchema(ctx, schema, componentStorage, sourceLocation) if err != nil { return err } @@ -295,11 +316,25 @@ func rewriteRefsInBundledSchemas(ctx context.Context, componentStorage *componen } // rewriteRefsInSchema rewrites references within a single schema -func rewriteRefsInSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceable], componentStorage *componentStorage) error { +func rewriteRefsInSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceable], componentStorage *componentStorage, sourceLocation string) error { if schema == nil { return nil } + // Extract just the URI part from sourceLocation (remove fragment if present) + // sourceLocation might be like "/path/to/file.yaml#/components/schemas/SchemaName" + // but we need just "/path/to/file.yaml" for resolving relative references + sourceURI := references.Reference(sourceLocation).GetURI() + if sourceURI == "" { + sourceURI = sourceLocation // Fallback if no URI part + } + + // On Windows, normalize sourceURI to backslashes before using with filepath operations + // This prevents malformed paths when joining with relative references + if filepath.Separator == '\\' && filepath.IsAbs(sourceURI) { + sourceURI = filepath.FromSlash(sourceURI) + } + // Walk through the schema and rewrite references for item := range oas3.Walk(ctx, schema) { err := item.Match(oas3.SchemaMatcher{ @@ -308,23 +343,16 @@ func rewriteRefsInSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Refer if schemaObj != nil && schemaObj.Ref != nil { refStr := schemaObj.Ref.String() - // Check for direct external reference match - if newName, exists := componentStorage.externalRefs[refStr]; exists { + // Convert the reference to absolute for lookup using the source URI (without fragment) + absRef, _ := handleReference(*schemaObj.Ref, sourceURI) + + // Check for direct reference match or circular reference + if newName, exists := componentStorage.refs[absRef]; exists { + newRef := "#/components/schemas/" + newName + *schemaObj.Ref = references.Reference(newRef) + } else if newName, found := findCircularReferenceMatch(refStr, componentStorage.refs); found { newRef := "#/components/schemas/" + newName *schemaObj.Ref = references.Reference(newRef) - } else if strings.HasPrefix(refStr, "#/") && !strings.HasPrefix(refStr, "#/components/") { - // Handle circular references within external schemas - // e.g., "#/User" should be mapped to "#/components/schemas/User_1" - defName := strings.TrimPrefix(refStr, "#/") - for externalRef, componentName := range componentStorage.externalRefs { - // Check if the external reference ends with this fragment - // e.g., "external_conflicting_user.yaml#/User" ends with "#/User" - if strings.HasSuffix(externalRef, "#/"+defName) { - newRef := "#/components/schemas/" + componentName - *schemaObj.Ref = references.Reference(newRef) - break - } - } } } return nil @@ -338,22 +366,23 @@ func rewriteRefsInSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Refer } // bundleGenericReference handles bundling of generic OpenAPI component references -func bundleGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreModeler](ctx context.Context, ref *Reference[T, V, C], namingStrategy BundleNamingStrategy, parentLocation string, opts ResolveOptions, componentStorage *componentStorage, componentType string) error { +func bundleGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreModeler](ctx context.Context, ref *Reference[T, V, C], namingStrategy BundleNamingStrategy, opts ResolveOptions, componentStorage *componentStorage, componentType string) error { if ref == nil || !ref.IsReference() { return nil } - refStr, classification := handleReference(ref.GetReference(), parentLocation, opts.TargetLocation) + refStr, classification := handleReference(ref.GetReference(), opts.TargetLocation) if classification == nil { return nil // Invalid reference, skip } - if classification.IsFragment { - return nil // Internal reference within the root document, skip + // Check if this is an internal reference to the root document + if isInternalReference(refStr, componentStorage.rootLocation) { + return nil } // Check if we've already processed this reference - if _, exists := componentStorage.externalRefs[refStr]; exists { + if _, exists := componentStorage.refs[refStr]; exists { return nil } @@ -370,14 +399,14 @@ func bundleGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreM } // Generate component name - componentName, err := generateComponentName(refStr, namingStrategy, componentStorage.componentNames, opts.TargetLocation) + componentName, err := generateComponentName(refStr, namingStrategy, componentStorage.componentNames, componentStorage.rootLocation) if err != nil { return fmt.Errorf("failed to generate component name for %s: %w", refStr, err) } componentStorage.componentNames[componentName] = true // Store the mapping - componentStorage.externalRefs[refStr] = componentName + componentStorage.refs[refStr] = componentName // Get the resolved content and create a new non-reference version resolvedValue := ref.GetObject() @@ -399,7 +428,7 @@ func bundleGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreM targetDocInfo := ref.GetReferenceResolutionInfo() - if err := bundleObject(ctx, bundledRef, namingStrategy, opts.TargetLocation, references.ResolveOptions{ + if err := bundleObject(ctx, bundledRef, namingStrategy, references.ResolveOptions{ RootDocument: opts.RootDocument, TargetDocument: targetDocInfo.ResolvedDocument, TargetLocation: targetDocInfo.AbsoluteReference, @@ -413,49 +442,26 @@ func bundleGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreM // generateComponentName creates a new component name based on the reference and naming strategy func generateComponentName(ref string, strategy BundleNamingStrategy, usedNames map[string]bool, targetLocation string) (string, error) { + // Convert absolute path back to relative for component naming + relativeRef := makeReferenceRelativeForNaming(ref, targetLocation) + switch strategy { case BundleNamingFilePath: - return generateFilePathBasedNameWithConflictResolution(ref, usedNames, targetLocation) + return generateFilePathBasedNameWithConflictResolution(relativeRef, usedNames, targetLocation) case BundleNamingCounter: - return generateCounterBasedName(ref, usedNames), nil + return generateCounterBasedName(relativeRef, usedNames), nil default: - return generateCounterBasedName(ref, usedNames), nil + return generateCounterBasedName(relativeRef, usedNames), nil } } // generateComponentNameWithHashConflictResolution creates a component name with smart conflict resolution based on content hashes func generateComponentNameWithHashConflictResolution(ref string, strategy BundleNamingStrategy, usedNames map[string]bool, schemaHashes map[string]string, resolvedHash string, targetLocation string) (string, error) { - // Parse the reference to extract the simple name - parts := strings.Split(ref, "#") - if len(parts) == 0 { - parts = []string{ref} // Fallback, though this should never happen - } - fragment := "" - if len(parts) > 1 { - fragment = parts[1] - } + // Convert absolute path back to relative for component naming + relativeRef := makeReferenceRelativeForNaming(ref, targetLocation) - var simpleName string - if fragment == "" || fragment == "/" { - // Top-level file reference - use filename as simple name - filePath := parts[0] - baseName := filepath.Base(filePath) - ext := filepath.Ext(baseName) - if ext != "" { - baseName = baseName[:len(baseName)-len(ext)] - } - simpleName = regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(baseName, "_") - } else { - // Reference to specific schema within file - extract schema name - cleanFragment := strings.TrimPrefix(fragment, "/") - fragmentParts := strings.Split(cleanFragment, "/") - if len(fragmentParts) == 0 { - // This should never happen as strings.Split never returns nil or empty slice - simpleName = "unknown" - } else { - simpleName = fragmentParts[len(fragmentParts)-1] - } - } + // Extract simple name from reference + simpleName := extractSimpleNameFromReference(relativeRef) // Check if a schema with this simple name already exists if existingHash, exists := schemaHashes[simpleName]; exists { @@ -464,8 +470,15 @@ func generateComponentNameWithHashConflictResolution(ref string, strategy Bundle return simpleName, nil } // Different content with same name - need conflict resolution - // Fall back to the configured naming strategy for conflict resolution - return generateComponentName(ref, strategy, usedNames, targetLocation) + // Fall back to the configured naming strategy for conflict resolution (use already-relative ref) + switch strategy { + case BundleNamingFilePath: + return generateFilePathBasedNameWithConflictResolution(relativeRef, usedNames, targetLocation) + case BundleNamingCounter: + return generateCounterBasedName(relativeRef, usedNames), nil + default: + return generateCounterBasedName(relativeRef, usedNames), nil + } } // No conflict, use simple name @@ -474,38 +487,8 @@ func generateComponentNameWithHashConflictResolution(ref string, strategy Bundle // generateFilePathBasedNameWithConflictResolution tries to use simple names first, falling back to file-path-based names for conflicts func generateFilePathBasedNameWithConflictResolution(ref string, usedNames map[string]bool, targetLocation string) (string, error) { - // Parse the reference to extract file path and fragment - parts := strings.Split(ref, "#") - if len(parts) == 0 { - // This should never happen as strings.Split never returns nil or empty slice - return "unknown", nil - } - fragment := "" - if len(parts) > 1 { - fragment = parts[1] - } - - var simpleName string - if fragment == "" || fragment == "/" { - // Top-level file reference - use filename as simple name - filePath := parts[0] - baseName := filepath.Base(filePath) - ext := filepath.Ext(baseName) - if ext != "" { - baseName = baseName[:len(baseName)-len(ext)] - } - simpleName = regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(baseName, "_") - } else { - // Reference to specific schema within file - extract schema name - cleanFragment := strings.TrimPrefix(fragment, "/") - fragmentParts := strings.Split(cleanFragment, "/") - if len(fragmentParts) == 0 { - // This should never happen as strings.Split never returns nil or empty slice - simpleName = "unknown" - } else { - simpleName = fragmentParts[len(fragmentParts)-1] - } - } + // Extract simple name from reference + simpleName := extractSimpleNameFromReference(ref) // Try simple name first if !usedNames[simpleName] { @@ -518,17 +501,10 @@ func generateFilePathBasedNameWithConflictResolution(ref string, usedNames map[s // generateFilePathBasedName creates names like "some_path_external_yaml~User" or "some_path_external_yaml" for top-level refs func generateFilePathBasedName(ref string, usedNames map[string]bool, targetLocation string) (string, error) { - // Parse the reference to extract file path and fragment - parts := strings.Split(ref, "#") - if len(parts) == 0 { - // This should never happen as strings.Split never returns nil or empty slice - return "unknown", nil - } - filePath := parts[0] - fragment := "" - if len(parts) > 1 { - fragment = parts[1] - } + // Parse the reference to extract file path and fragment using references package + reference := references.Reference(ref) + filePath := reference.GetURI() + fragment := string(reference.GetJSONPointer()) // Convert full file path to safe component name // Clean the path but keep extension for uniqueness @@ -537,13 +513,15 @@ func generateFilePathBasedName(ref string, usedNames map[string]bool, targetLoca // Remove leading "./" if present cleanPath = strings.TrimPrefix(cleanPath, "./") - // Handle parent directory references more elegantly - // Instead of converting "../" to "___", we'll normalize the path - normalizedPath, err := normalizePathForComponentName(cleanPath, targetLocation) - if err != nil { - return "", fmt.Errorf("failed to normalize path %s: %w", cleanPath, err) + // Normalize paths that are absolute OR contain parent directory references (..) + if targetLocation != "" && (filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, "..")) { + // Normalize to get actual directory names instead of ../ + normalizedPath, err := normalizePathForComponentName(cleanPath, targetLocation) + if err != nil { + return "", fmt.Errorf("failed to normalize path %s: %w", cleanPath, err) + } + cleanPath = normalizedPath } - cleanPath = normalizedPath // Replace extension dot with underscore to keep it but make it safe ext := filepath.Ext(cleanPath) @@ -671,38 +649,8 @@ func normalizePathForComponentName(path, targetLocation string) (string, error) // generateCounterBasedName creates names like "User_1", "User_2" for conflicts func generateCounterBasedName(ref string, usedNames map[string]bool) string { - // Extract the schema name from the reference - parts := strings.Split(ref, "#") - if len(parts) == 0 { - // This should never happen as strings.Split never returns nil or empty slice - return "unknown" - } - fragment := "" - if len(parts) > 1 { - fragment = parts[1] - } - - var baseName string - if fragment == "" || fragment == "/" { - // Top-level file reference - use filename - filePath := parts[0] - baseName = filepath.Base(filePath) - ext := filepath.Ext(baseName) - if ext != "" { - baseName = baseName[:len(baseName)-len(ext)] - } - // Replace unsafe characters - baseName = regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(baseName, "_") - } else { - // Extract last part of fragment as schema name - fragmentParts := strings.Split(strings.TrimPrefix(fragment, "/"), "/") - if len(fragmentParts) == 0 { - // This should never happen as strings.Split never returns nil or empty slice - baseName = "unknown" - } else { - baseName = fragmentParts[len(fragmentParts)-1] - } - } + // Extract simple name from reference + baseName := extractSimpleNameFromReference(ref) // Ensure uniqueness with counter componentName := baseName @@ -722,23 +670,17 @@ func updateReferencesToComponents(ctx context.Context, doc *OpenAPI, componentSt Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { if schema.IsReference() { ref := string(schema.GetRef()) - if newName, exists := componentStorage.externalRefs[ref]; exists { + + // Convert the reference to absolute for lookup using root location + absRef, _ := handleReference(schema.GetRef(), componentStorage.rootLocation) + + if newName, exists := componentStorage.refs[absRef]; exists { // Update the reference to point to the new component newRef := "#/components/schemas/" + newName *schema.GetLeft().Ref = references.Reference(newRef) - } else if strings.HasPrefix(ref, "#/") && !strings.HasPrefix(ref, "#/components/") { - // Handle circular references within external schemas - // Look for a matching external reference that ends with this fragment - for externalRef, componentName := range componentStorage.externalRefs { - // Check if the external reference ends with this fragment - // e.g., "external_conflicting_user.yaml#/User" ends with "#/User" - if strings.HasSuffix(externalRef, ref) { - // Update the circular reference to point to the bundled component - newRef := "#/components/schemas/" + componentName - *schema.GetLeft().Ref = references.Reference(newRef) - break - } - } + } else if newName, found := findCircularReferenceMatch(ref, componentStorage.refs); found { + newRef := "#/components/schemas/" + newName + *schema.GetLeft().Ref = references.Reference(newRef) } } return nil @@ -784,8 +726,10 @@ func updateReference[T any, V interfaces.Validator[T], C marshaller.CoreModeler] return nil } - refStr := string(ref.GetReference()) - if newName, exists := componentStorage.externalRefs[refStr]; exists { + // Convert the reference to absolute for lookup using root location + absRef, _ := handleReference(ref.GetReference(), componentStorage.rootLocation) + + if newName, exists := componentStorage.refs[absRef]; exists { // Update the reference to point to the new component newRef := "#/components/" + componentSection + "/" + newName *ref.Reference = references.Reference(newRef) @@ -901,7 +845,23 @@ func addComponentsToDocument(doc *OpenAPI, componentStorage *componentStorage) { } } -func handleReference(ref references.Reference, parentLocation, targetLocation string) (string, *utils.ReferenceClassification) { +// handleReference processes a reference for bundling by converting it to an absolute path. +// This function is specifically designed for the Bundle operation, which needs to: +// - Track all external references using absolute paths as unique identifiers +// - Normalize paths to forward slashes for cross-platform consistency in the refs map +// - Handle both file-based and URL-based references uniformly +// +// This differs from handleLocalizeReference, which preserves relative paths and original +// path separators to maintain the document structure during file copying operations. +// +// Parameters: +// - ref: The reference to process +// - targetLocation: The absolute path of the document containing this reference +// +// Returns: +// - string: The absolute reference path (normalized to forward slashes for file paths) +// - *utils.ReferenceClassification: Classification of the reference type, or nil if invalid +func handleReference(ref references.Reference, targetLocation string) (string, *utils.ReferenceClassification) { r := ref.String() // Check if this is an external reference using the utility function @@ -910,44 +870,152 @@ func handleReference(ref references.Reference, parentLocation, targetLocation st return "", nil // Invalid reference, skip } - // For URLs, don't do any path manipulation - return as-is + // For URLs, they're already absolute - return as-is if classification.Type == utils.ReferenceTypeURL { return r, classification } - if parentLocation != "" { - relPath, err := filepath.Rel(filepath.Dir(parentLocation), targetLocation) - if err == nil { - if classification.IsFragment { - r = relPath + r - } else { - if ref.GetURI() != "" { - r = filepath.Join(filepath.Dir(relPath), r) + // If we have a target location, make the reference absolute + if targetLocation != "" { + // Classify the target location to determine how to join + baseClassification, err := utils.ClassifyReference(targetLocation) + if err != nil { + // Invalid base location - cannot proceed with reference classification + return "", nil + } + + var absolutePath string + + // For fragment-only references, prepend the target location + if classification.IsFragment { + absolutePath = targetLocation + r + } else { + // For file path references, join with the target location + if baseClassification.IsURL { + // Base is URL, join using URL resolution + joined, err := baseClassification.JoinWith(r) + if err == nil { + absolutePath = joined } else { - r = filepath.Join(relPath, r) + // Error joining URLs is not fatal - fall back to using the original reference + // This can happen with malformed URLs or incompatible URL structures + absolutePath = r } + } else { + // Base is file path, resolve relative path + // Base location is assumed to be absolute at this point + // Split reference into path and fragment using references package + refParsed := references.Reference(r) + filePath := refParsed.GetURI() + fragment := "" + if refParsed.HasJSONPointer() { + fragment = "#" + string(refParsed.GetJSONPointer()) + } + + // Join with target directory + baseDir := filepath.Dir(targetLocation) + joinedPath := filepath.Join(baseDir, filePath) + + // Clean the path and immediately normalize to forward slashes + // This prevents issues with Windows path handling + absPath := filepath.Clean(joinedPath) + absPath = filepath.ToSlash(absPath) + + absolutePath = absPath + fragment } } - // convert paths back to original separators - // detect original separators from the original reference - pathStyle := detectPathStyle(ref.String()) - switch pathStyle { - case "windows": - r = strings.ReplaceAll(r, "/", "\\") - default: - r = strings.ReplaceAll(r, "\\", "/") + r = absolutePath + + // Normalize to forward slashes for cross-platform path consistency + // This ensures that C:\path and C:/path are treated the same + // Apply normalization to the full absolute path (including fragment) + refParsed := references.Reference(r) + uri := refParsed.GetURI() + + // Always normalize absolute paths to forward slashes + if uri != "" { + // Check if it's an absolute path (works for both C:\path and C:/path) + isAbs := filepath.IsAbs(uri) || (len(uri) >= 3 && uri[1] == ':' && (uri[2] == '/' || uri[2] == '\\')) + + if isAbs { + normalizedURI := filepath.ToSlash(uri) + if refParsed.HasJSONPointer() { + r = normalizedURI + "#" + string(refParsed.GetJSONPointer()) + } else { + r = normalizedURI + } + } } + // Re-classify after making absolute and normalizing cl, err := utils.ClassifyReference(r) if err == nil { classification = cl } + // Error re-classifying is not fatal - we keep using the previous classification + // This maintains backward compatibility and allows processing to continue } return r, classification } +// makeReferenceRelativeForNaming converts an absolute reference path back to a relative path +// suitable for component naming, relative to the root document location (assumed to be absolute) +func makeReferenceRelativeForNaming(ref string, rootLocation string) string { + if rootLocation == "" { + return ref + } + + // Parse reference using the references package + reference := references.Reference(ref) + uri := reference.GetURI() + + // If there's no URI (just a fragment), return as-is + if uri == "" { + return ref + } + + // On Windows, paths with forward slashes can be misclassified as URLs + // Normalize to native separators before classification to avoid this + normalizedURI := uri + if filepath.Separator == '\\' && len(uri) >= 3 && uri[1] == ':' && uri[2] == '/' { + // Windows path with forward slashes like C:/path - convert to backslashes + normalizedURI = filepath.FromSlash(uri) + } + + // Check if this is a URL - if so, return as-is + // Error classifying reference is not fatal - we return the original ref unchanged + classification, err := utils.ClassifyReference(normalizedURI) + if err != nil || classification.IsURL { + return ref + } + + // If the URI is absolute, make it relative to the root document's directory + // rootLocation is assumed to be absolute at this point + if filepath.IsAbs(normalizedURI) { + // Normalize rootLocation as well for consistent comparison + normalizedRoot := rootLocation + if filepath.Separator == '\\' { + normalizedRoot = filepath.FromSlash(rootLocation) + } + rootDir := filepath.Dir(normalizedRoot) + relPath, err := filepath.Rel(rootDir, normalizedURI) + if err == nil { + // Reconstruct the reference with relative path + if reference.HasJSONPointer() { + return relPath + "#" + string(reference.GetJSONPointer()) + } + return relPath + } + // Error making path relative is not fatal - we fall through to return the original ref + // This can happen when paths are on different drives (Windows) or incompatible + } + + // Return as-is if we couldn't make it relative + return ref +} + var winAbs = regexp.MustCompile(`^[a-zA-Z]:\\`) func detectPathStyle(p string) string { @@ -962,3 +1030,60 @@ func detectPathStyle(p string) string { return "unknown" } } + +// Helper functions for DRY principle + +// isInternalReference checks if a reference points to the root document +func isInternalReference(ref string, rootLocation string) bool { + refURI := references.Reference(ref).GetURI() + if refURI == "" { + return true // Fragment-only reference + } + + cleanRefURI := filepath.Clean(refURI) + cleanRootURI := filepath.Clean(rootLocation) + return cleanRefURI == cleanRootURI +} + +// extractSimpleNameFromReference extracts a simple component name from a reference +func extractSimpleNameFromReference(ref string) string { + reference := references.Reference(ref) + filePath := reference.GetURI() + fragment := string(reference.GetJSONPointer()) + + if fragment == "" || fragment == "/" { + // Top-level file reference - use filename as simple name + baseName := filepath.Base(filePath) + ext := filepath.Ext(baseName) + if ext != "" { + baseName = baseName[:len(baseName)-len(ext)] + } + return regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(baseName, "_") + } + + // Reference to specific schema within file - extract schema name + cleanFragment := strings.TrimPrefix(fragment, "/") + fragmentParts := strings.Split(cleanFragment, "/") + if len(fragmentParts) == 0 { + return "unknown" + } + return fragmentParts[len(fragmentParts)-1] +} + +// findCircularReferenceMatch finds a component name for a circular reference +func findCircularReferenceMatch(refStr string, refs map[string]string) (string, bool) { + // Only match fragment-only references that aren't already component references + if !strings.HasPrefix(refStr, "#/") || strings.HasPrefix(refStr, "#/components/") { + return "", false + } + + // Look for a matching reference that ends with this fragment + // e.g., "/absolute/path/external_conflicting_user.yaml#/User" ends with "#/User" + for externalRef, componentName := range refs { + if strings.HasSuffix(externalRef, refStr) { + return componentName, true + } + } + + return "", false +} diff --git a/openapi/bundle_test.go b/openapi/bundle_test.go index 478985f..e9cf55e 100644 --- a/openapi/bundle_test.go +++ b/openapi/bundle_test.go @@ -172,3 +172,44 @@ func TestBundle_SiblingDirectories_Success(t *testing.T) { // Compare the actual output with expected output assert.Equal(t, string(expectedBytes), string(actualYAML), "Bundled document should match expected output") } + +func TestBundle_Issue50_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/bundle/issue50/test/testapi.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Configure bundling options + opts := openapi.BundleOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: inputDoc, + TargetLocation: "testdata/bundle/issue50/test/testapi.yaml", + }, + NamingStrategy: openapi.BundleNamingFilePath, + } + + // Bundle all external references + err = openapi.Bundle(ctx, inputDoc, opts) + require.NoError(t, err) + + // Marshal the bundled document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/bundle/issue50/expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "Bundled document should match expected output") +} diff --git a/openapi/localize.go b/openapi/localize.go index a087010..82e00f7 100644 --- a/openapi/localize.go +++ b/openapi/localize.go @@ -213,7 +213,7 @@ func discoverSchemaReference(ctx context.Context, schema *oas3.JSONSchema[oas3.R return nil } - ref, classification := handleReference(schema.GetRef(), "", opts.TargetLocation) + ref, classification := handleLocalizeReference(schema.GetRef(), "", opts.TargetLocation) if classification == nil || classification.IsFragment { return nil // Skip internal references } @@ -287,7 +287,7 @@ func discoverGenericReference[T any, V interfaces.Validator[T], C marshaller.Cor return nil } - refStr, classification := handleReference(ref.GetReference(), "", opts.TargetLocation) + refStr, classification := handleLocalizeReference(ref.GetReference(), "", opts.TargetLocation) if classification == nil || classification.IsFragment { return nil // Skip internal references } @@ -450,6 +450,7 @@ func generateLocalizedFilenameWithConflictDetection(ref string, strategy Localiz // generatePathBasedFilenameWithConflictDetection creates filenames with smart conflict resolution func generatePathBasedFilenameWithConflictDetection(filePath string, _ bool, usedFilenames map[string]bool) string { // Check if this is a URL - if so, extract filename from URL path + // Error classifying reference is not fatal - we fall through to file path handling if classification, err := utils.ClassifyReference(filePath); err == nil && classification.Type == utils.ReferenceTypeURL { // For URLs, extract the filename from the URL path if lastSlash := strings.LastIndex(filePath, "/"); lastSlash != -1 { @@ -627,6 +628,7 @@ func rewriteReferenceValue(refValue, originalRef string, storage *localizeStorag // For URLs, use the full reference as the key, for file paths normalize var normalizedFilePath string + // Error classifying reference is not fatal - we fall through to file path handling if classification, err := utils.ClassifyReference(resolvedRef); err == nil && classification.Type == utils.ReferenceTypeURL { normalizedFilePath = resolvedRef // Use the full URL as the key } else { @@ -668,6 +670,7 @@ func resolveRelativeReference(ref, baseRef string) string { refPath := refObj.GetURI() // Check if the base reference is a URL + // Error classifying base reference is not fatal - we fall through to file path handling if classification, err := utils.ClassifyReference(baseURI); err == nil && classification.Type == utils.ReferenceTypeURL { // For URLs, use URL path joining instead of file path joining var resolvedPath string @@ -734,6 +737,7 @@ func rewriteReferencesToLocalized(ctx context.Context, doc *OpenAPI, storage *lo // For URLs, use the full reference as the key, for file paths normalize var normalizedFilePath string + // Error classifying reference is not fatal - we fall through to file path handling if classification, err := utils.ClassifyReference(string(ref)); err == nil && classification.Type == utils.ReferenceTypeURL { normalizedFilePath = string(ref) // Use the full URL as the key } else { @@ -799,6 +803,7 @@ func updateGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreM // For URLs, use the full reference as the key, for file paths normalize var normalizedFilePath string + // Error classifying reference is not fatal - we fall through to file path handling if classification, err := utils.ClassifyReference(string(refObj)); err == nil && classification.Type == utils.ReferenceTypeURL { normalizedFilePath = string(refObj) // Use the full URL as the key } else { @@ -821,6 +826,7 @@ func updateGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreM // normalizeFilePath normalizes a file path for consistent handling func normalizeFilePath(filePath string) string { // Check if this is a URL - if so, don't apply file path normalization + // Error classifying reference is not fatal - we treat it as a file path if classification, err := utils.ClassifyReference(filePath); err == nil && classification.Type == utils.ReferenceTypeURL { return filePath // Return URLs as-is } @@ -833,3 +839,82 @@ func normalizeFilePath(filePath string) string { return cleanPath } + +// handleLocalizeReference processes a reference for localization by preserving relative path structures. +// This function represents the original reference handling behavior and is specifically designed +// for the Localize operation, which needs to: +// - Preserve the original document structure and path relationships during file copying +// - Maintain the original path separators (Windows vs Unix) for generated files +// - Support relative path manipulation when copying nested referenced files +// +// This differs from handleReference (used in Bundle), which: +// - Normalizes all paths to absolute with forward slashes for use as unique map keys +// - Doesn't need to preserve original path structure since everything goes into components +// - Requires consistent path format for deduplication and reference rewriting +// +// Key behavioral differences: +// 1. Path normalization: handleLocalizeReference preserves path style (Windows/Unix), +// while handleReference normalizes to forward slashes +// 2. Path manipulation: handleLocalizeReference supports parentLocation for relative +// path calculations when processing nested references +// 3. Return format: handleLocalizeReference may return relative paths based on context, +// while handleReference always returns absolute paths when targetLocation is provided +// +// Parameters: +// - ref: The reference to process +// - parentLocation: The location of the parent document (for relative path calculations) +// - targetLocation: The location of the current document containing this reference +// +// Returns: +// - string: The processed reference path (may be relative or absolute depending on context) +// - *utils.ReferenceClassification: Classification of the reference type, or nil if invalid +func handleLocalizeReference(ref references.Reference, parentLocation, targetLocation string) (string, *utils.ReferenceClassification) { + r := ref.String() + + // Check if this is an external reference using the utility function + classification, err := utils.ClassifyReference(r) + if err != nil { + return "", nil // Invalid reference, skip + } + + // For URLs, don't do any path manipulation - return as-is + if classification.Type == utils.ReferenceTypeURL { + return r, classification + } + + if parentLocation != "" { + relPath, err := filepath.Rel(filepath.Dir(parentLocation), targetLocation) + if err == nil { + if classification.IsFragment { + r = relPath + r + } else { + if ref.GetURI() != "" { + r = filepath.Join(filepath.Dir(relPath), r) + } else { + r = filepath.Join(relPath, r) + } + } + } + // Error getting relative path is not fatal - fall back to using the original reference + // This can happen when paths are on different drives (Windows) or other path incompatibilities + + // convert paths back to original separators + // detect original separators from the original reference + pathStyle := detectPathStyle(ref.String()) + switch pathStyle { + case "windows": + r = strings.ReplaceAll(r, "/", "\\") + default: + r = strings.ReplaceAll(r, "\\", "/") + } + + cl, err := utils.ClassifyReference(r) + if err == nil { + classification = cl + } + // Error re-classifying is not fatal - we keep using the previous classification + // This maintains backward compatibility and allows processing to continue + } + + return r, classification +} diff --git a/openapi/testdata/bundle/issue50/actual.yaml b/openapi/testdata/bundle/issue50/actual.yaml new file mode 100644 index 0000000..2a0519d --- /dev/null +++ b/openapi/testdata/bundle/issue50/actual.yaml @@ -0,0 +1,301 @@ +openapi: 3.1.0 +info: + title: test API + version: "0.1" + description: test + license: + name: apache 2 + url: "https://apache.org/licenses/LICENSE-2.0" +servers: + - url: "https://localhost:8086" + description: local dev server +security: + - bearerAuth: [] +paths: + "/v3/entities": + post: + summary: create a entity + operationId: post-v3-entities + tags: + - entities + x-permissions: + - "entity:create" + requestBody: + $ref: "#/components/requestBodies/create-entity" + responses: + "201": + $ref: "#/components/responses/entity-response" + "500": + $ref: "#/components/responses/internal-server-error" + parameters: + - $ref: "#/components/parameters/X-Idempotency-Key" +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + CreateEntity: + description: Create a entity + oneOf: + - $ref: "#/components/schemas/CreateEntityIndividual" + - $ref: "#/components/schemas/CreateEntityCorporate" + discriminator: + propertyName: entity_type + mapping: + individual: "#/components/schemas/CreateEntityIndividual" + corporate: "#/components/schemas/CreateEntityCorporate" + CreateEntityIndividual: + title: CreateEntityIndividual + description: Create a entity individual + type: object + required: + - entity_type + - name + properties: + entity_type: + type: string + enum: + - individual + email: + $ref: "#/components/schemas/Email" + name: + type: string + example: John + maxLength: 100 + pattern: "^[A-Za-z\\s\\-\\.']+" + street: + type: string + example: 1 rue de la bourse + maxLength: 255 + pattern: "^[\\p{L}\\p{N}\\s\\.,\\-#'\"/()]+$" + city: + $ref: "#/components/schemas/PermissiveString" + country: + $ref: "#/components/schemas/CountryCode2" + birth_date: + $ref: "#/components/schemas/Date" + CreateEntityCorporate: + title: CreateEntityCorporate + description: Create a entity corporate + type: object + required: + - entity_type + - company_name + properties: + entity_type: + type: string + enum: + - corporate + email: + $ref: "#/components/schemas/Email" + country: + $ref: "#/components/schemas/CountryCode2" + company_name: + type: string + example: Wisoky - Berge + maxLength: 100 + pattern: '^[a-zA-Z0-9\s\-&,\.]+$' + incorporation_date: + $ref: "#/components/schemas/Date" + UUID: + type: string + title: UUID + maxLength: 36 + minLength: 36 + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ + format: uuid + example: 123e4567-e89b-7023-a456-426614174000 + ProblemDetail: + type: object + properties: + type: + type: string + maxLength: 2000 + minLength: 0 + format: uri + example: "https://example.com/probs/out-of-credit" + title: + type: string + maxLength: 1000 + minLength: 0 + pattern: ^[\w\s\-\.,'"!?()]+$ + example: You do not have enough credit. + detail: + type: string + maxLength: 1000 + minLength: 0 + pattern: ^[\w\s\-\.,'"!?()]+$ + example: "Your current balance is 30, but that costs 50." + instance: + type: string + maxLength: 1000 + minLength: 0 + format: uri + example: /account/12345/msgs/abc + title: ProblemDetail + required: + - type + - title + - detail + - instance + description: RFC9457 + x-examples: + out of credit: + type: "https://example.com/probs/out-of-credit" + title: You do not have enough credit. + detail: "Your current balance is 30, but that costs 50." + instance: /account/12345/msgs/abc + Email: + type: string + maxLength: 254 + format: email + example: random@example.com + PermissiveString: + type: string + maxLength: 500 + minLength: 0 + pattern: ^[\p{L}\p{N}\p{P}\p{S}\s]*$ + example: "lorem ipsum" + CountryCode2: + type: string + title: CountryCode2 + enum: + - AW + - AF + - FR + example: FR + Date: + type: string + title: Date + maxLength: 200 + minLength: 0 + format: date + description: date + example: "2024-06-31" + IndividualEntity: + type: object + properties: + id: + $ref: '#/components/schemas/EntityID' + email: + $ref: '#/components/schemas/Email' + name: + $ref: '#/components/schemas/PermissiveString' + country: + $ref: '#/components/schemas/CountryCode2' + birth_date: + $ref: '#/components/schemas/Date' + title: IndividualEntity + description: individual entity + EntityID: + type: string + title: Entity id + maxLength: 30 + minLength: 30 + pattern: ^xxx- + description: entity id + example: xxx-01hzy23cq44ae6k7vy5jtp8bef + CorporateEntity: + type: object + properties: + id: + $ref: '#/components/schemas/EntityID' + name: + $ref: '#/components/schemas/PermissiveString' + email: + $ref: '#/components/schemas/Email' + country: + $ref: '#/components/schemas/CountryCode2' + company_name: + $ref: '#/components/schemas/PermissiveString' + incorporation_date: + $ref: '#/components/schemas/Date' + title: CorporateEntity + description: corporate entity + responses: + entity-response: + description: Entity details + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + Retry-After: + $ref: "#/components/headers/Retry-After" + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/IndividualEntity" + - $ref: "#/components/schemas/CorporateEntity" + internal-server-error: + description: Internal server error + headers: + X-RateLimit-Limit: + $ref: '#/components/headers/X-RateLimit-Limit' + X-RateLimit-Remaining: + $ref: '#/components/headers/X-RateLimit-Remaining' + X-RateLimit-Reset: + $ref: '#/components/headers/X-RateLimit-Reset' + Retry-After: + $ref: '#/components/headers/Retry-After' + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + examples: + Example 1: + value: + type: "https://example.com/v2/problems/internal-server-error" + title: Internal Server Error + instance: accounts/123 + detail: "An unexpected error occurred on the server." + requestBodies: + create-entity: + description: Entity creation payload + content: + application/json: + schema: + $ref: "#/components/schemas/CreateEntity" + parameters: + X-Idempotency-Key: + name: X-Idempotency-Key + in: header + required: true + schema: + $ref: '#/components/schemas/UUID' + headers: + X-RateLimit-Limit: + schema: + type: integer + maximum: 10000 + minimum: 1 + format: int32 + example: 100 + X-RateLimit-Remaining: + schema: + type: integer + maximum: 10000 + minimum: 0 + format: int32 + example: 99 + X-RateLimit-Reset: + schema: + type: integer + maximum: 2.147483647e+09 + minimum: 0 + format: int32 + example: 1652364907 + Retry-After: + schema: + type: integer + maximum: 3600 + minimum: 0 + format: int32 + example: 60 +tags: + - name: entities + description: Operations related to entity management diff --git a/openapi/testdata/bundle/issue50/common.yaml b/openapi/testdata/bundle/issue50/common.yaml new file mode 100644 index 0000000..9886fd2 --- /dev/null +++ b/openapi/testdata/bundle/issue50/common.yaml @@ -0,0 +1,184 @@ +openapi: 3.0.0 +info: + title: common definitions + version: "1.0" + description: common definitions + license: + name: apache 2 + url: "https://apache.org/licenses/LICENSE-2.0" +paths: + /dummy: + get: + summary: Dummy endpoint to satisfy OpenAPI validator + responses: + "200": + description: Successful response +components: + responses: + internal-server-error: + description: Internal server error + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + Retry-After: + $ref: "#/components/headers/Retry-After" + content: + application/json: + schema: + $ref: "#/components/schemas/ProblemDetail" + examples: + Example 1: + value: + type: "https://example.com/v2/problems/internal-server-error" + title: Internal Server Error + instance: accounts/123 + detail: "An unexpected error occurred on the server." + + headers: + X-RateLimit-Limit: + schema: + type: integer + format: int32 + minimum: 1 + maximum: 10000 + example: 100 + X-RateLimit-Remaining: + schema: + type: integer + format: int32 + minimum: 0 + maximum: 10000 + example: 99 + X-RateLimit-Reset: + schema: + type: integer + format: int32 + minimum: 0 + maximum: 2147483647 + example: 1652364907 + Retry-After: + schema: + type: integer + format: int32 + minimum: 0 + maximum: 3600 + example: 60 + schemas: + IndividualEntity: + title: IndividualEntity + type: object + description: individual entity + properties: + id: + $ref: "#/components/schemas/EntityID" + email: + $ref: "#/components/schemas/Email" + name: + $ref: "#/components/schemas/PermissiveString" + country: + $ref: "./country.yaml#/components/schemas/CountryCode2" + birth_date: + $ref: "#/components/schemas/Date" + CorporateEntity: + title: CorporateEntity + description: corporate entity + type: object + properties: + id: + $ref: "#/components/schemas/EntityID" + name: + $ref: "#/components/schemas/PermissiveString" + email: + $ref: "#/components/schemas/Email" + country: + $ref: "./country.yaml#/components/schemas/CountryCode2" + company_name: + $ref: "#/components/schemas/PermissiveString" + incorporation_date: + $ref: "#/components/schemas/Date" + ProblemDetail: + title: ProblemDetail + type: object + description: RFC9457 + required: + - type + - title + - detail + - instance + properties: + type: + type: string + example: "https://example.com/probs/out-of-credit" + maxLength: 2000 + minLength: 0 + format: uri + title: + type: string + example: You do not have enough credit. + minLength: 0 + maxLength: 1000 + pattern: "^[\\w\\s\\-\\.,'\"!?()]+$" + detail: + type: string + example: "Your current balance is 30, but that costs 50." + minLength: 0 + maxLength: 1000 + pattern: "^[\\w\\s\\-\\.,'\"!?()]+$" + instance: + type: string + example: /account/12345/msgs/abc + minLength: 0 + maxLength: 1000 + format: uri + x-examples: + out of credit: + type: "https://example.com/probs/out-of-credit" + title: You do not have enough credit. + detail: "Your current balance is 30, but that costs 50." + instance: /account/12345/msgs/abc + UUID: + title: UUID + type: string + example: 123e4567-e89b-7023-a456-426614174000 + minLength: 36 + maxLength: 36 + format: uuid + pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + EntityID: + type: string + title: Entity id + maxLength: 30 + minLength: 30 + pattern: ^xxx- + description: entity id + example: xxx-01hzy23cq44ae6k7vy5jtp8bef + PermissiveString: + type: string + example: "lorem ipsum" + minLength: 0 + maxLength: 500 + pattern: '^[\p{L}\p{N}\p{P}\p{S}\s]*$' + Date: + title: Date + description: date + type: string + format: date + minLength: 0 + maxLength: 200 + example: "2024-06-31" + Email: + type: string + format: email + example: random@example.com + maxLength: 254 + parameters: + X-Idempotency-Key: + name: X-Idempotency-Key + in: header + required: true + schema: + $ref: "#/components/schemas/UUID" diff --git a/openapi/testdata/bundle/issue50/country.yaml b/openapi/testdata/bundle/issue50/country.yaml new file mode 100644 index 0000000..1b51cba --- /dev/null +++ b/openapi/testdata/bundle/issue50/country.yaml @@ -0,0 +1,25 @@ +openapi: 3.0.0 +info: + title: common definitions + version: "1.0" + description: common definitions + license: + name: apache 2 + url: "https://apache.org/licenses/LICENSE-2.0" +paths: + /dummy: + get: + summary: Dummy endpoint to satisfy OpenAPI validator + responses: + "200": + description: Successful response +components: + schemas: + CountryCode2: + title: CountryCode2 + type: string + enum: + - AW + - AF + - FR + example: FR diff --git a/openapi/testdata/bundle/issue50/expected.yaml b/openapi/testdata/bundle/issue50/expected.yaml new file mode 100644 index 0000000..2a0519d --- /dev/null +++ b/openapi/testdata/bundle/issue50/expected.yaml @@ -0,0 +1,301 @@ +openapi: 3.1.0 +info: + title: test API + version: "0.1" + description: test + license: + name: apache 2 + url: "https://apache.org/licenses/LICENSE-2.0" +servers: + - url: "https://localhost:8086" + description: local dev server +security: + - bearerAuth: [] +paths: + "/v3/entities": + post: + summary: create a entity + operationId: post-v3-entities + tags: + - entities + x-permissions: + - "entity:create" + requestBody: + $ref: "#/components/requestBodies/create-entity" + responses: + "201": + $ref: "#/components/responses/entity-response" + "500": + $ref: "#/components/responses/internal-server-error" + parameters: + - $ref: "#/components/parameters/X-Idempotency-Key" +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + CreateEntity: + description: Create a entity + oneOf: + - $ref: "#/components/schemas/CreateEntityIndividual" + - $ref: "#/components/schemas/CreateEntityCorporate" + discriminator: + propertyName: entity_type + mapping: + individual: "#/components/schemas/CreateEntityIndividual" + corporate: "#/components/schemas/CreateEntityCorporate" + CreateEntityIndividual: + title: CreateEntityIndividual + description: Create a entity individual + type: object + required: + - entity_type + - name + properties: + entity_type: + type: string + enum: + - individual + email: + $ref: "#/components/schemas/Email" + name: + type: string + example: John + maxLength: 100 + pattern: "^[A-Za-z\\s\\-\\.']+" + street: + type: string + example: 1 rue de la bourse + maxLength: 255 + pattern: "^[\\p{L}\\p{N}\\s\\.,\\-#'\"/()]+$" + city: + $ref: "#/components/schemas/PermissiveString" + country: + $ref: "#/components/schemas/CountryCode2" + birth_date: + $ref: "#/components/schemas/Date" + CreateEntityCorporate: + title: CreateEntityCorporate + description: Create a entity corporate + type: object + required: + - entity_type + - company_name + properties: + entity_type: + type: string + enum: + - corporate + email: + $ref: "#/components/schemas/Email" + country: + $ref: "#/components/schemas/CountryCode2" + company_name: + type: string + example: Wisoky - Berge + maxLength: 100 + pattern: '^[a-zA-Z0-9\s\-&,\.]+$' + incorporation_date: + $ref: "#/components/schemas/Date" + UUID: + type: string + title: UUID + maxLength: 36 + minLength: 36 + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ + format: uuid + example: 123e4567-e89b-7023-a456-426614174000 + ProblemDetail: + type: object + properties: + type: + type: string + maxLength: 2000 + minLength: 0 + format: uri + example: "https://example.com/probs/out-of-credit" + title: + type: string + maxLength: 1000 + minLength: 0 + pattern: ^[\w\s\-\.,'"!?()]+$ + example: You do not have enough credit. + detail: + type: string + maxLength: 1000 + minLength: 0 + pattern: ^[\w\s\-\.,'"!?()]+$ + example: "Your current balance is 30, but that costs 50." + instance: + type: string + maxLength: 1000 + minLength: 0 + format: uri + example: /account/12345/msgs/abc + title: ProblemDetail + required: + - type + - title + - detail + - instance + description: RFC9457 + x-examples: + out of credit: + type: "https://example.com/probs/out-of-credit" + title: You do not have enough credit. + detail: "Your current balance is 30, but that costs 50." + instance: /account/12345/msgs/abc + Email: + type: string + maxLength: 254 + format: email + example: random@example.com + PermissiveString: + type: string + maxLength: 500 + minLength: 0 + pattern: ^[\p{L}\p{N}\p{P}\p{S}\s]*$ + example: "lorem ipsum" + CountryCode2: + type: string + title: CountryCode2 + enum: + - AW + - AF + - FR + example: FR + Date: + type: string + title: Date + maxLength: 200 + minLength: 0 + format: date + description: date + example: "2024-06-31" + IndividualEntity: + type: object + properties: + id: + $ref: '#/components/schemas/EntityID' + email: + $ref: '#/components/schemas/Email' + name: + $ref: '#/components/schemas/PermissiveString' + country: + $ref: '#/components/schemas/CountryCode2' + birth_date: + $ref: '#/components/schemas/Date' + title: IndividualEntity + description: individual entity + EntityID: + type: string + title: Entity id + maxLength: 30 + minLength: 30 + pattern: ^xxx- + description: entity id + example: xxx-01hzy23cq44ae6k7vy5jtp8bef + CorporateEntity: + type: object + properties: + id: + $ref: '#/components/schemas/EntityID' + name: + $ref: '#/components/schemas/PermissiveString' + email: + $ref: '#/components/schemas/Email' + country: + $ref: '#/components/schemas/CountryCode2' + company_name: + $ref: '#/components/schemas/PermissiveString' + incorporation_date: + $ref: '#/components/schemas/Date' + title: CorporateEntity + description: corporate entity + responses: + entity-response: + description: Entity details + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + Retry-After: + $ref: "#/components/headers/Retry-After" + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/IndividualEntity" + - $ref: "#/components/schemas/CorporateEntity" + internal-server-error: + description: Internal server error + headers: + X-RateLimit-Limit: + $ref: '#/components/headers/X-RateLimit-Limit' + X-RateLimit-Remaining: + $ref: '#/components/headers/X-RateLimit-Remaining' + X-RateLimit-Reset: + $ref: '#/components/headers/X-RateLimit-Reset' + Retry-After: + $ref: '#/components/headers/Retry-After' + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + examples: + Example 1: + value: + type: "https://example.com/v2/problems/internal-server-error" + title: Internal Server Error + instance: accounts/123 + detail: "An unexpected error occurred on the server." + requestBodies: + create-entity: + description: Entity creation payload + content: + application/json: + schema: + $ref: "#/components/schemas/CreateEntity" + parameters: + X-Idempotency-Key: + name: X-Idempotency-Key + in: header + required: true + schema: + $ref: '#/components/schemas/UUID' + headers: + X-RateLimit-Limit: + schema: + type: integer + maximum: 10000 + minimum: 1 + format: int32 + example: 100 + X-RateLimit-Remaining: + schema: + type: integer + maximum: 10000 + minimum: 0 + format: int32 + example: 99 + X-RateLimit-Reset: + schema: + type: integer + maximum: 2.147483647e+09 + minimum: 0 + format: int32 + example: 1652364907 + Retry-After: + schema: + type: integer + maximum: 3600 + minimum: 0 + format: int32 + example: 60 +tags: + - name: entities + description: Operations related to entity management diff --git a/openapi/testdata/bundle/issue50/test/testapi.yaml b/openapi/testdata/bundle/issue50/test/testapi.yaml new file mode 100644 index 0000000..868a83c --- /dev/null +++ b/openapi/testdata/bundle/issue50/test/testapi.yaml @@ -0,0 +1,129 @@ +openapi: 3.1.0 +info: + title: test API + version: "0.1" + description: test + license: + name: apache 2 + url: "https://apache.org/licenses/LICENSE-2.0" +servers: + - url: "https://localhost:8086" + description: local dev server +security: + - bearerAuth: [] +paths: + "/v3/entities": + post: + summary: create a entity + operationId: post-v3-entities + tags: + - entities + x-permissions: + - "entity:create" + requestBody: + $ref: "#/components/requestBodies/create-entity" + responses: + "201": + $ref: "#/components/responses/entity-response" + "500": + $ref: "../common.yaml#/components/responses/internal-server-error" + parameters: + - $ref: "../common.yaml#/components/parameters/X-Idempotency-Key" +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + CreateEntity: + description: Create a entity + oneOf: + - $ref: "#/components/schemas/CreateEntityIndividual" + - $ref: "#/components/schemas/CreateEntityCorporate" + discriminator: + propertyName: entity_type + mapping: + individual: "#/components/schemas/CreateEntityIndividual" + corporate: "#/components/schemas/CreateEntityCorporate" + CreateEntityIndividual: + title: CreateEntityIndividual + description: Create a entity individual + type: object + required: + - entity_type + - name + properties: + entity_type: + type: string + enum: + - individual + email: + $ref: "../common.yaml#/components/schemas/Email" + name: + type: string + example: John + maxLength: 100 + pattern: "^[A-Za-z\\s\\-\\.']+" + street: + type: string + example: 1 rue de la bourse + maxLength: 255 + pattern: "^[\\p{L}\\p{N}\\s\\.,\\-#'\"/()]+$" + city: + $ref: "../common.yaml#/components/schemas/PermissiveString" + country: + $ref: "../country.yaml#/components/schemas/CountryCode2" + birth_date: + $ref: "../common.yaml#/components/schemas/Date" + CreateEntityCorporate: + title: CreateEntityCorporate + description: Create a entity corporate + type: object + required: + - entity_type + - company_name + properties: + entity_type: + type: string + enum: + - corporate + email: + $ref: "../common.yaml#/components/schemas/Email" + country: + $ref: "../country.yaml#/components/schemas/CountryCode2" + company_name: + type: string + example: Wisoky - Berge + maxLength: 100 + pattern: '^[a-zA-Z0-9\s\-&,\.]+$' + incorporation_date: + $ref: "../common.yaml#/components/schemas/Date" + responses: + entity-response: + description: Entity details + headers: + X-RateLimit-Limit: + $ref: "../common.yaml#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "../common.yaml#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "../common.yaml#/components/headers/X-RateLimit-Reset" + Retry-After: + $ref: "../common.yaml#/components/headers/Retry-After" + content: + application/json: + schema: + oneOf: + - $ref: "../common.yaml#/components/schemas/IndividualEntity" + - $ref: "../common.yaml#/components/schemas/CorporateEntity" + + requestBodies: + create-entity: + description: Entity creation payload + content: + application/json: + schema: + $ref: "#/components/schemas/CreateEntity" +tags: + - name: entities + description: Operations related to entity management diff --git a/openapi/testdata/inline/bundled_counter_expected.yaml b/openapi/testdata/inline/bundled_counter_expected.yaml index 399fc8e..b9074c4 100644 --- a/openapi/testdata/inline/bundled_counter_expected.yaml +++ b/openapi/testdata/inline/bundled_counter_expected.yaml @@ -738,7 +738,7 @@ components: posts: type: array items: - $ref: external_post.yaml + $ref: '#/components/schemas/external_post' description: Posts created by this user required: - id diff --git a/openapi/testdata/inline/bundled_expected.yaml b/openapi/testdata/inline/bundled_expected.yaml index 3f94402..1a285a3 100644 --- a/openapi/testdata/inline/bundled_expected.yaml +++ b/openapi/testdata/inline/bundled_expected.yaml @@ -738,7 +738,7 @@ components: posts: type: array items: - $ref: external_post.yaml + $ref: '#/components/schemas/external_post' description: Posts created by this user required: - id diff --git a/validation/errors.go b/validation/errors.go index f5cbd34..447757f 100644 --- a/validation/errors.go +++ b/validation/errors.go @@ -37,19 +37,23 @@ func (e Error) GetColumnNumber() int { return e.Node.Column } -type valueNodeGetter interface { +// ValueNodeGetter provides access to value nodes for error reporting. +type ValueNodeGetter interface { GetValueNodeOrRoot(root *yaml.Node) *yaml.Node } -type sliceNodeGetter interface { +// SliceNodeGetter provides access to slice element nodes for error reporting. +type SliceNodeGetter interface { GetSliceValueNodeOrRoot(index int, root *yaml.Node) *yaml.Node } -type mapKeyNodeGetter interface { +// MapKeyNodeGetter provides access to map key nodes for error reporting. +type MapKeyNodeGetter interface { GetMapKeyNodeOrRoot(key string, root *yaml.Node) *yaml.Node } -type mapValueNodeGetter interface { +// MapValueNodeGetter provides access to map value nodes for error reporting. +type MapValueNodeGetter interface { GetMapValueNodeOrRoot(key string, root *yaml.Node) *yaml.Node } @@ -64,7 +68,7 @@ type CoreModeler interface { GetRootNode() *yaml.Node } -func NewValueError(err error, core CoreModeler, node valueNodeGetter) error { +func NewValueError(err error, core CoreModeler, node ValueNodeGetter) error { rootNode := core.GetRootNode() if rootNode == nil { @@ -82,7 +86,7 @@ func NewValueError(err error, core CoreModeler, node valueNodeGetter) error { } } -func NewSliceError(err error, core CoreModeler, node sliceNodeGetter, index int) error { +func NewSliceError(err error, core CoreModeler, node SliceNodeGetter, index int) error { rootNode := core.GetRootNode() if rootNode == nil { @@ -100,7 +104,7 @@ func NewSliceError(err error, core CoreModeler, node sliceNodeGetter, index int) } } -func NewMapKeyError(err error, core CoreModeler, node mapKeyNodeGetter, key string) error { +func NewMapKeyError(err error, core CoreModeler, node MapKeyNodeGetter, key string) error { rootNode := core.GetRootNode() if rootNode == nil { @@ -118,7 +122,7 @@ func NewMapKeyError(err error, core CoreModeler, node mapKeyNodeGetter, key stri } } -func NewMapValueError(err error, core CoreModeler, node mapValueNodeGetter, key string) error { +func NewMapValueError(err error, core CoreModeler, node MapValueNodeGetter, key string) error { rootNode := core.GetRootNode() if rootNode == nil { diff --git a/validation/validation_test.go b/validation/validation_test.go index 0eabd1f..ce7cf5b 100644 --- a/validation/validation_test.go +++ b/validation/validation_test.go @@ -230,7 +230,7 @@ func TestNewValueError_Success(t *testing.T) { tests := []struct { name string core CoreModeler - nodeGetter valueNodeGetter + nodeGetter ValueNodeGetter expectedNode *yaml.Node }{ { @@ -287,7 +287,7 @@ func TestNewSliceError_Success(t *testing.T) { tests := []struct { name string core CoreModeler - nodeGetter sliceNodeGetter + nodeGetter SliceNodeGetter index int expectedNode *yaml.Node }{ @@ -337,7 +337,7 @@ func TestNewMapKeyError_Success(t *testing.T) { tests := []struct { name string core CoreModeler - nodeGetter mapKeyNodeGetter + nodeGetter MapKeyNodeGetter key string expectedNode *yaml.Node }{ @@ -387,7 +387,7 @@ func TestNewMapValueError_Success(t *testing.T) { tests := []struct { name string core CoreModeler - nodeGetter mapValueNodeGetter + nodeGetter MapValueNodeGetter key string expectedNode *yaml.Node }{