diff --git a/openapi/cmd/join.go b/openapi/cmd/join.go new file mode 100644 index 0000000..090cfba --- /dev/null +++ b/openapi/cmd/join.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/spf13/cobra" +) + +// joinWriteInPlace controls whether to write the joined document back to the main file +var joinWriteInPlace bool + +// joinStrategy controls the conflict resolution strategy for joined components +var joinStrategy string + +var joinCmd = &cobra.Command{ + Use: "join [document2...] [output-file]", + Short: "Join multiple OpenAPI documents into a single document", + Long: `Join combines multiple OpenAPI documents into a single unified document with intelligent conflict resolution. + +This command merges OpenAPI specifications by: +• Combining all paths, components, and operations from multiple documents +• Resolving naming conflicts using configurable strategies +• Handling servers and security requirements intelligently +• Preserving external references while joining documents +• Maintaining document integrity and validation + +The join operation supports two conflict resolution strategies: +• counter: Uses counter-based suffixes like User_1, User_2 for conflicts +• filepath: Uses file path-based naming like second_yaml~User + +Smart conflict handling: +• Components: Identical components are merged, conflicts are renamed +• Operations: Path conflicts use fragment-based naming (/users~1) +• Servers/Security: Conflicts push settings to operation level +• Tags: Unique tags are appended, identical tags are preserved + +Examples: + # Join to stdout (pipe-friendly) + openapi openapi join ./main.yaml ./api1.yaml ./api2.yaml + + # Join to specific file + openapi openapi join ./main.yaml ./api1.yaml ./api2.yaml ./joined.yaml + + # Join in-place with counter strategy + openapi openapi join -w --strategy counter ./main.yaml ./api1.yaml + + # Join with filepath strategy (default) + openapi openapi join --strategy filepath ./main.yaml ./api1.yaml ./joined.yaml`, + Args: cobra.MinimumNArgs(2), + RunE: runJoinCommand, +} + +func init() { + joinCmd.Flags().BoolVarP(&joinWriteInPlace, "write", "w", false, "Write joined document back to main file") + joinCmd.Flags().StringVar(&joinStrategy, "strategy", "counter", "Conflict resolution strategy (counter|filepath)") +} + +func runJoinCommand(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Parse arguments - last arg might be output file if it doesn't exist as input + mainFile := args[0] + var documentFiles []string + var outputFile string + + // Determine if last argument is an output file (doesn't exist) or input file (exists) + if len(args) >= 3 { + lastArg := args[len(args)-1] + if _, err := os.Stat(lastArg); os.IsNotExist(err) { + // Last argument doesn't exist, treat as output file + documentFiles = args[1 : len(args)-1] + outputFile = lastArg + } else { + // All arguments are input files + documentFiles = args[1:] + } + } else { + // Only main file and one document file + documentFiles = args[1:] + } + + // Validate strategy + var strategy openapi.JoinConflictStrategy + switch joinStrategy { + case "counter": + strategy = openapi.JoinConflictCounter + case "filepath": + strategy = openapi.JoinConflictFilePath + default: + return fmt.Errorf("invalid strategy: %s (must be 'counter' or 'filepath')", joinStrategy) + } + + // Create processor + processor, err := NewOpenAPIProcessor(mainFile, outputFile, joinWriteInPlace) + if err != nil { + return err + } + + // Load main document + mainDoc, validationErrors, err := processor.LoadDocument(ctx) + if err != nil { + return err + } + + // Report validation errors for main document + processor.ReportValidationErrors(validationErrors) + + // Load additional documents + var documents []*openapi.OpenAPI + var filePaths []string + + for _, docFile := range documentFiles { + // Create a temporary processor for each document to load it + docProcessor, err := NewOpenAPIProcessor(docFile, "", false) + if err != nil { + return fmt.Errorf("failed to create processor for %s: %w", docFile, err) + } + + doc, docValidationErrors, err := docProcessor.LoadDocument(ctx) + if err != nil { + return fmt.Errorf("failed to load document %s: %w", docFile, err) + } + + // Report validation errors for this document + if len(docValidationErrors) > 0 && !processor.WriteToStdout { + fmt.Printf("⚠️ Found %d validation errors in %s:\n", len(docValidationErrors), docFile) + for i, validationErr := range docValidationErrors { + fmt.Printf(" %d. %s\n", i+1, validationErr.Error()) + } + fmt.Println() + } + + documents = append(documents, doc) + filePaths = append(filePaths, docFile) + } + + // Prepare join options + opts := openapi.JoinOptions{ + ConflictStrategy: strategy, + } + + if strategy == openapi.JoinConflictFilePath { + // Create document path mappings for filepath strategy + opts.DocumentPaths = make(map[int]string) + for i, path := range filePaths { + opts.DocumentPaths[i] = path + } + } + + // Prepare document info slice + var documentInfos []openapi.JoinDocumentInfo + for i, doc := range documents { + docInfo := openapi.JoinDocumentInfo{ + Document: doc, + } + if i < len(filePaths) { + docInfo.FilePath = filePaths[i] + } + documentInfos = append(documentInfos, docInfo) + } + + // Perform the join operation (modifies mainDoc in place) + if err := openapi.Join(ctx, mainDoc, documentInfos, opts); err != nil { + return fmt.Errorf("failed to join documents: %w", err) + } + + // Print success message + processor.PrintSuccess(fmt.Sprintf("Successfully joined %d documents with %s strategy", len(documents)+1, joinStrategy)) + + // Write the joined document (mainDoc was modified in place) + if err := processor.WriteDocument(ctx, mainDoc); err != nil { + return err + } + + return nil +} diff --git a/openapi/cmd/root.go b/openapi/cmd/root.go index 8d9b406..e88d414 100644 --- a/openapi/cmd/root.go +++ b/openapi/cmd/root.go @@ -8,4 +8,5 @@ func Apply(rootCmd *cobra.Command) { rootCmd.AddCommand(upgradeCmd) rootCmd.AddCommand(inlineCmd) rootCmd.AddCommand(bundleCmd) + rootCmd.AddCommand(joinCmd) } diff --git a/openapi/join.go b/openapi/join.go new file mode 100644 index 0000000..e6a6abb --- /dev/null +++ b/openapi/join.go @@ -0,0 +1,842 @@ +package openapi + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/speakeasy-api/openapi/hashing" + "github.com/speakeasy-api/openapi/internal/interfaces" + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/references" + "github.com/speakeasy-api/openapi/sequencedmap" +) + +// JoinConflictStrategy defines how conflicts should be resolved when joining documents. +type JoinConflictStrategy int + +const ( + // JoinConflictCounter uses counter-based suffixes like User_1, User_2 for conflicts + JoinConflictCounter JoinConflictStrategy = iota + // JoinConflictFilePath uses file path-based naming like file_path_somefile_yaml~User + JoinConflictFilePath +) + +// JoinOptions represents the options available when joining OpenAPI documents. +type JoinOptions struct { + // ConflictStrategy determines how conflicts are resolved when joining documents. + ConflictStrategy JoinConflictStrategy + // DocumentPaths maps each document to its file path for conflict resolution. + // The key should match the document's position in the documents slice. + DocumentPaths map[int]string +} + +// JoinDocumentInfo holds information about a document being joined. +type JoinDocumentInfo struct { + Document *OpenAPI + FilePath string +} + +// Join combines multiple OpenAPI documents into one, using conflict resolution strategies +// similar to bundling but without inlining external references. This creates a single +// document that retains references to external documents while resolving conflicts +// between local components and operations. +// +// The main document serves as the base: +// - Its Info and OpenAPI version fields are retained +// - For conflicting servers, security, and tags, the main document's values are kept +// +// For other fields: +// - Operations, components, webhooks, and extensions are appended from all documents +// - Operation conflicts create new paths with fragments containing the file name +// - Component conflicts use the same strategy as bundling (counter or filepath naming) +// +// Parameters: +// - ctx: Context for cancellation and timeout control +// - mainDoc: The main document that serves as the base (will be modified in place) +// - documents: Slice of JoinDocumentInfo containing additional documents and their file paths +// - opts: Configuration options for joining +// +// Returns: +// - error: Any error that occurred during joining +func Join(ctx context.Context, mainDoc *OpenAPI, documents []JoinDocumentInfo, opts JoinOptions) error { + if mainDoc == nil { + return errors.New("main document is nil") + } + + // Track used names for conflict resolution + usedComponentNames := make(map[string]bool) + componentHashes := make(map[string]string) + usedPathNames := make(map[string]bool) + + // Initialize tracking with existing components and paths from main document + initializeUsedNames(mainDoc, usedComponentNames, componentHashes, usedPathNames) + + // Join all additional documents + for i, docInfo := range documents { + doc := docInfo.Document + if doc == nil { + continue + } + + docPath := docInfo.FilePath + if docPath == "" { + // Use index as fallback if no path provided + docPath = fmt.Sprintf("document_%d", i) + } + + err := joinSingleDocument(ctx, mainDoc, doc, docPath, opts, usedComponentNames, componentHashes, usedPathNames) + if err != nil { + return fmt.Errorf("failed to join document %d (%s): %w", i, docPath, err) + } + } + + return nil +} + +// initializeUsedNames populates the tracking maps with existing names from the lead document +func initializeUsedNames(doc *OpenAPI, usedComponentNames map[string]bool, componentHashes map[string]string, usedPathNames map[string]bool) { + // Track existing component names and hashes + if doc.Components != nil { + if doc.Components.Schemas != nil { + for name, schema := range doc.Components.Schemas.All() { + usedComponentNames[name] = true + if schema != nil { + componentHashes[name] = hashing.Hash(schema) + } + } + } + + // Track other component types + trackComponentNames := func(components interface{}) { + switch c := components.(type) { + case *sequencedmap.Map[string, *ReferencedResponse]: + if c != nil { + for name := range c.All() { + usedComponentNames[name] = true + } + } + case *sequencedmap.Map[string, *ReferencedParameter]: + if c != nil { + for name := range c.All() { + usedComponentNames[name] = true + } + } + case *sequencedmap.Map[string, *ReferencedExample]: + if c != nil { + for name := range c.All() { + usedComponentNames[name] = true + } + } + case *sequencedmap.Map[string, *ReferencedRequestBody]: + if c != nil { + for name := range c.All() { + usedComponentNames[name] = true + } + } + case *sequencedmap.Map[string, *ReferencedHeader]: + if c != nil { + for name := range c.All() { + usedComponentNames[name] = true + } + } + case *sequencedmap.Map[string, *ReferencedSecurityScheme]: + if c != nil { + for name := range c.All() { + usedComponentNames[name] = true + } + } + case *sequencedmap.Map[string, *ReferencedLink]: + if c != nil { + for name := range c.All() { + usedComponentNames[name] = true + } + } + case *sequencedmap.Map[string, *ReferencedCallback]: + if c != nil { + for name := range c.All() { + usedComponentNames[name] = true + } + } + case *sequencedmap.Map[string, *ReferencedPathItem]: + if c != nil { + for name := range c.All() { + usedComponentNames[name] = true + } + } + } + } + + trackComponentNames(doc.Components.Responses) + trackComponentNames(doc.Components.Parameters) + trackComponentNames(doc.Components.Examples) + trackComponentNames(doc.Components.RequestBodies) + trackComponentNames(doc.Components.Headers) + trackComponentNames(doc.Components.SecuritySchemes) + trackComponentNames(doc.Components.Links) + trackComponentNames(doc.Components.Callbacks) + trackComponentNames(doc.Components.PathItems) + } + + // Track existing path names + if doc.Paths != nil { + for path := range doc.Paths.All() { + usedPathNames[path] = true + } + } + +} + +// joinSingleDocument joins a single document into the result document +func joinSingleDocument(ctx context.Context, result, doc *OpenAPI, docPath string, opts JoinOptions, usedComponentNames map[string]bool, componentHashes map[string]string, usedPathNames map[string]bool) error { + // Track component name mappings for reference updates + componentMappings := make(map[string]string) + + // Join components with conflict resolution first (to get mappings) + if err := joinComponents(result, doc, docPath, opts.ConflictStrategy, usedComponentNames, componentHashes, componentMappings); err != nil { + return fmt.Errorf("failed to join components: %w", err) + } + + // Update references in the document before joining paths and webhooks + if err := updateReferencesInDocument(ctx, doc, componentMappings); err != nil { + return fmt.Errorf("failed to update references in document: %w", err) + } + + // Join paths with conflict resolution + joinPaths(result, doc, docPath, usedPathNames) + + // Join webhooks + joinWebhooks(result, doc) + + // Join tags with conflict resolution (append unless conflicting names) + joinTags(result, doc) + + // Join servers and security with smart conflict resolution + joinServersAndSecurity(result, doc) + + return nil +} + +// joinPaths joins paths from source document into result, handling operation conflicts +func joinPaths(result, src *OpenAPI, srcPath string, usedPathNames map[string]bool) { + if src.Paths == nil { + return + } + + // Ensure result has paths + if result.Paths == nil { + result.Paths = NewPaths() + } + + for path, pathItem := range src.Paths.All() { + if !usedPathNames[path] { + // No conflict, add directly + result.Paths.Set(path, pathItem) + usedPathNames[path] = true + } else { + // Conflict detected, create new path with fragment + newPath := generateConflictPath(path, srcPath) + result.Paths.Set(newPath, pathItem) + usedPathNames[newPath] = true + } + } + +} + +// generateConflictPath creates a new path with a fragment containing the file name +func generateConflictPath(originalPath, filePath string) string { + // Extract filename without extension for the fragment + baseName := filepath.Base(filePath) + ext := filepath.Ext(baseName) + if ext != "" { + baseName = baseName[:len(baseName)-len(ext)] + } + + // Clean the filename to make it URL-safe + safeFileName := regexp.MustCompile(`[^a-zA-Z0-9_-]`).ReplaceAllString(baseName, "_") + + // Create new path with fragment + return fmt.Sprintf("%s#%s", originalPath, safeFileName) +} + +// joinWebhooks joins webhooks from source document into result +func joinWebhooks(result, src *OpenAPI) { + if src.Webhooks == nil { + return + } + + // Ensure result has webhooks + if result.Webhooks == nil { + result.Webhooks = sequencedmap.New[string, *ReferencedPathItem]() + } + + for name, webhook := range src.Webhooks.All() { + // For webhooks, we append all - no conflict resolution needed as they're named + result.Webhooks.Set(name, webhook) + } + +} + +// joinComponents joins components from source document into result with conflict resolution +func joinComponents(result, src *OpenAPI, srcPath string, strategy JoinConflictStrategy, usedComponentNames map[string]bool, componentHashes map[string]string, componentMappings map[string]string) error { + if src.Components == nil { + return nil + } + + // Ensure result has components + if result.Components == nil { + result.Components = &Components{} + } + + // Join schemas with hash-based conflict resolution + joinSchemas(result.Components, src.Components, srcPath, strategy, usedComponentNames, componentHashes, componentMappings) + + // Join other component types + if err := joinOtherComponents(result.Components, src.Components, srcPath, strategy, usedComponentNames, componentHashes, componentMappings); err != nil { + return fmt.Errorf("failed to join other components: %w", err) + } + + return nil +} + +// joinSchemas joins schemas with smart conflict resolution based on content hashes +func joinSchemas(resultComponents, srcComponents *Components, srcPath string, strategy JoinConflictStrategy, usedComponentNames map[string]bool, componentHashes map[string]string, componentMappings map[string]string) { + if srcComponents.Schemas == nil { + return + } + + // Ensure result has schemas + if resultComponents.Schemas == nil { + resultComponents.Schemas = sequencedmap.New[string, *oas3.JSONSchema[oas3.Referenceable]]() + } + + for name, schema := range srcComponents.Schemas.All() { + if schema == nil { + continue + } + + schemaHash := hashing.Hash(schema) + + // Check if a schema with this name already exists + if existingHash, exists := componentHashes[name]; exists { + if existingHash == schemaHash { + // Same content, skip (no need to add duplicate) + continue + } + // Different content with same name - need conflict resolution + newName := generateJoinComponentName(name, srcPath, strategy, usedComponentNames) + resultComponents.Schemas.Set(newName, schema) + usedComponentNames[newName] = true + componentHashes[newName] = schemaHash + // Track the mapping for reference updates + componentMappings[name] = newName + } else { + // No conflict, use original name + resultComponents.Schemas.Set(name, schema) + usedComponentNames[name] = true + componentHashes[name] = schemaHash + } + } + +} + +// joinOtherComponents joins non-schema components with conflict resolution +func joinOtherComponents(resultComponents, srcComponents *Components, srcPath string, strategy JoinConflictStrategy, usedComponentNames map[string]bool, componentHashes map[string]string, componentMappings map[string]string) error { + // Helper function to join a specific component type + joinComponentType := func( + getResult func() interface{}, + getSrc func() interface{}, + setResult func(interface{}), + createNew func() interface{}, + ) error { + srcMap := getSrc() + if srcMap == nil { + return nil + } + + // Ensure result has this component type + resultMap := getResult() + if resultMap == nil { + resultMap = createNew() + setResult(resultMap) + } + + // Use reflection-like approach to handle different map types + switch src := srcMap.(type) { + case *sequencedmap.Map[string, *ReferencedResponse]: + result := resultMap.(*sequencedmap.Map[string, *ReferencedResponse]) + for name, item := range src.All() { + finalName := name + if usedComponentNames[name] { + finalName = generateJoinComponentName(name, srcPath, strategy, usedComponentNames) + componentMappings[name] = finalName + } + result.Set(finalName, item) + usedComponentNames[finalName] = true + } + case *sequencedmap.Map[string, *ReferencedParameter]: + result := resultMap.(*sequencedmap.Map[string, *ReferencedParameter]) + for name, item := range src.All() { + finalName := name + if usedComponentNames[name] { + finalName = generateJoinComponentName(name, srcPath, strategy, usedComponentNames) + componentMappings[name] = finalName + } + result.Set(finalName, item) + usedComponentNames[finalName] = true + } + case *sequencedmap.Map[string, *ReferencedExample]: + result := resultMap.(*sequencedmap.Map[string, *ReferencedExample]) + for name, item := range src.All() { + finalName := name + if usedComponentNames[name] { + finalName = generateJoinComponentName(name, srcPath, strategy, usedComponentNames) + componentMappings[name] = finalName + } + result.Set(finalName, item) + usedComponentNames[finalName] = true + } + case *sequencedmap.Map[string, *ReferencedRequestBody]: + result := resultMap.(*sequencedmap.Map[string, *ReferencedRequestBody]) + for name, item := range src.All() { + finalName := name + if usedComponentNames[name] { + finalName = generateJoinComponentName(name, srcPath, strategy, usedComponentNames) + componentMappings[name] = finalName + } + result.Set(finalName, item) + usedComponentNames[finalName] = true + } + case *sequencedmap.Map[string, *ReferencedHeader]: + result := resultMap.(*sequencedmap.Map[string, *ReferencedHeader]) + for name, item := range src.All() { + finalName := name + if usedComponentNames[name] { + finalName = generateJoinComponentName(name, srcPath, strategy, usedComponentNames) + componentMappings[name] = finalName + } + result.Set(finalName, item) + usedComponentNames[finalName] = true + } + case *sequencedmap.Map[string, *ReferencedSecurityScheme]: + result := resultMap.(*sequencedmap.Map[string, *ReferencedSecurityScheme]) + for name, item := range src.All() { + if item == nil { + continue + } + + itemHash := hashing.Hash(item) + + // Check if a security scheme with this name already exists + if existingHash, exists := componentHashes[name]; exists { + if existingHash == itemHash { + // Same content, skip (no need to add duplicate) + continue + } + // Different content with same name - need conflict resolution + finalName := generateJoinComponentName(name, srcPath, strategy, usedComponentNames) + result.Set(finalName, item) + usedComponentNames[finalName] = true + componentHashes[finalName] = itemHash + componentMappings[name] = finalName + } else { + // No conflict, use original name + result.Set(name, item) + usedComponentNames[name] = true + componentHashes[name] = itemHash + } + } + case *sequencedmap.Map[string, *ReferencedLink]: + result := resultMap.(*sequencedmap.Map[string, *ReferencedLink]) + for name, item := range src.All() { + finalName := name + if usedComponentNames[name] { + finalName = generateJoinComponentName(name, srcPath, strategy, usedComponentNames) + componentMappings[name] = finalName + } + result.Set(finalName, item) + usedComponentNames[finalName] = true + } + case *sequencedmap.Map[string, *ReferencedCallback]: + result := resultMap.(*sequencedmap.Map[string, *ReferencedCallback]) + for name, item := range src.All() { + finalName := name + if usedComponentNames[name] { + finalName = generateJoinComponentName(name, srcPath, strategy, usedComponentNames) + componentMappings[name] = finalName + } + result.Set(finalName, item) + usedComponentNames[finalName] = true + } + case *sequencedmap.Map[string, *ReferencedPathItem]: + result := resultMap.(*sequencedmap.Map[string, *ReferencedPathItem]) + for name, item := range src.All() { + finalName := name + if usedComponentNames[name] { + finalName = generateJoinComponentName(name, srcPath, strategy, usedComponentNames) + componentMappings[name] = finalName + } + result.Set(finalName, item) + usedComponentNames[finalName] = true + } + } + + return nil + } + + // Join responses + if err := joinComponentType( + func() interface{} { return resultComponents.Responses }, + func() interface{} { return srcComponents.Responses }, + func(v interface{}) { resultComponents.Responses = v.(*sequencedmap.Map[string, *ReferencedResponse]) }, + func() interface{} { return sequencedmap.New[string, *ReferencedResponse]() }, + ); err != nil { + return err + } + + // Join parameters + if err := joinComponentType( + func() interface{} { return resultComponents.Parameters }, + func() interface{} { return srcComponents.Parameters }, + func(v interface{}) { resultComponents.Parameters = v.(*sequencedmap.Map[string, *ReferencedParameter]) }, + func() interface{} { return sequencedmap.New[string, *ReferencedParameter]() }, + ); err != nil { + return err + } + + // Join examples + if err := joinComponentType( + func() interface{} { return resultComponents.Examples }, + func() interface{} { return srcComponents.Examples }, + func(v interface{}) { resultComponents.Examples = v.(*sequencedmap.Map[string, *ReferencedExample]) }, + func() interface{} { return sequencedmap.New[string, *ReferencedExample]() }, + ); err != nil { + return err + } + + // Join request bodies + if err := joinComponentType( + func() interface{} { return resultComponents.RequestBodies }, + func() interface{} { return srcComponents.RequestBodies }, + func(v interface{}) { + resultComponents.RequestBodies = v.(*sequencedmap.Map[string, *ReferencedRequestBody]) + }, + func() interface{} { return sequencedmap.New[string, *ReferencedRequestBody]() }, + ); err != nil { + return err + } + + // Join headers + if err := joinComponentType( + func() interface{} { return resultComponents.Headers }, + func() interface{} { return srcComponents.Headers }, + func(v interface{}) { resultComponents.Headers = v.(*sequencedmap.Map[string, *ReferencedHeader]) }, + func() interface{} { return sequencedmap.New[string, *ReferencedHeader]() }, + ); err != nil { + return err + } + + // Join security schemes + if err := joinComponentType( + func() interface{} { return resultComponents.SecuritySchemes }, + func() interface{} { return srcComponents.SecuritySchemes }, + func(v interface{}) { + resultComponents.SecuritySchemes = v.(*sequencedmap.Map[string, *ReferencedSecurityScheme]) + }, + func() interface{} { return sequencedmap.New[string, *ReferencedSecurityScheme]() }, + ); err != nil { + return err + } + + // Join links + if err := joinComponentType( + func() interface{} { return resultComponents.Links }, + func() interface{} { return srcComponents.Links }, + func(v interface{}) { resultComponents.Links = v.(*sequencedmap.Map[string, *ReferencedLink]) }, + func() interface{} { return sequencedmap.New[string, *ReferencedLink]() }, + ); err != nil { + return err + } + + // Join callbacks + if err := joinComponentType( + func() interface{} { return resultComponents.Callbacks }, + func() interface{} { return srcComponents.Callbacks }, + func(v interface{}) { resultComponents.Callbacks = v.(*sequencedmap.Map[string, *ReferencedCallback]) }, + func() interface{} { return sequencedmap.New[string, *ReferencedCallback]() }, + ); err != nil { + return err + } + + // Join path items + if err := joinComponentType( + func() interface{} { return resultComponents.PathItems }, + func() interface{} { return srcComponents.PathItems }, + func(v interface{}) { resultComponents.PathItems = v.(*sequencedmap.Map[string, *ReferencedPathItem]) }, + func() interface{} { return sequencedmap.New[string, *ReferencedPathItem]() }, + ); err != nil { + return err + } + + return nil +} + +// generateJoinComponentName creates a new component name using the same strategy as bundling +func generateJoinComponentName(originalName, filePath string, strategy JoinConflictStrategy, usedNames map[string]bool) string { + switch strategy { + case JoinConflictFilePath: + return generateJoinFilePathBasedName(originalName, filePath, usedNames) + case JoinConflictCounter: + return generateJoinCounterBasedName(originalName, usedNames) + default: + return generateJoinCounterBasedName(originalName, usedNames) + } +} + +// generateJoinFilePathBasedName creates names like "some_path_external_yaml~User" +func generateJoinFilePathBasedName(originalName, filePath string, usedNames map[string]bool) string { + // Convert file path to safe component name + cleanPath := filepath.Clean(filePath) + cleanPath = strings.TrimPrefix(cleanPath, "./") + + // Replace extension dot with underscore + ext := filepath.Ext(cleanPath) + if ext != "" { + cleanPath = cleanPath[:len(cleanPath)-len(ext)] + "_" + ext[1:] + } + + // Replace path separators and unsafe characters with underscores + safeFileName := regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(cleanPath, "_") + + componentName := safeFileName + "~" + originalName + + // Ensure uniqueness + originalComponentName := componentName + counter := 1 + for usedNames[componentName] { + componentName = fmt.Sprintf("%s_%d", originalComponentName, counter) + counter++ + } + + return componentName +} + +// generateJoinCounterBasedName creates names like "User_1", "User_2" for conflicts +func generateJoinCounterBasedName(originalName string, usedNames map[string]bool) string { + componentName := originalName + counter := 1 + for usedNames[componentName] { + componentName = fmt.Sprintf("%s_%d", originalName, counter) + counter++ + } + + return componentName +} + +// updateReferencesInDocument updates all references in a document to use the new component names +func updateReferencesInDocument(ctx context.Context, doc *OpenAPI, componentMappings map[string]string) error { + if len(componentMappings) == 0 { + return nil + } + + // Walk through the document and update references + for item := range Walk(ctx, doc) { + err := item.Match(Matcher{ + Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { + if schema.IsReference() { + ref := string(schema.GetRef()) + // Check if this is a component reference that needs updating + if strings.HasPrefix(ref, "#/components/schemas/") { + componentName := strings.TrimPrefix(ref, "#/components/schemas/") + if newName, exists := componentMappings[componentName]; exists { + newRef := "#/components/schemas/" + newName + *schema.GetLeft().Ref = references.Reference(newRef) + } + } + } + return nil + }, + ReferencedResponse: func(ref *ReferencedResponse) error { + return updateComponentReference(ref, componentMappings, "responses") + }, + ReferencedParameter: func(ref *ReferencedParameter) error { + return updateComponentReference(ref, componentMappings, "parameters") + }, + ReferencedExample: func(ref *ReferencedExample) error { + return updateComponentReference(ref, componentMappings, "examples") + }, + ReferencedRequestBody: func(ref *ReferencedRequestBody) error { + return updateComponentReference(ref, componentMappings, "requestBodies") + }, + ReferencedHeader: func(ref *ReferencedHeader) error { + return updateComponentReference(ref, componentMappings, "headers") + }, + ReferencedCallback: func(ref *ReferencedCallback) error { + return updateComponentReference(ref, componentMappings, "callbacks") + }, + ReferencedLink: func(ref *ReferencedLink) error { + return updateComponentReference(ref, componentMappings, "links") + }, + ReferencedSecurityScheme: func(ref *ReferencedSecurityScheme) error { + return updateComponentReference(ref, componentMappings, "securitySchemes") + }, + ReferencedPathItem: func(ref *ReferencedPathItem) error { + return updateComponentReference(ref, componentMappings, "pathItems") + }, + }) + if err != nil { + return fmt.Errorf("failed to update reference at %s: %w", item.Location.ToJSONPointer().String(), err) + } + } + + return nil +} + +// updateComponentReference updates a generic component reference to use the new name +func updateComponentReference[T any, V interfaces.Validator[T], C marshaller.CoreModeler](ref *Reference[T, V, C], componentMappings map[string]string, componentSection string) error { + if ref == nil || !ref.IsReference() { + return nil + } + + refStr := string(ref.GetReference()) + expectedPrefix := "#/components/" + componentSection + "/" + if strings.HasPrefix(refStr, expectedPrefix) { + componentName := strings.TrimPrefix(refStr, expectedPrefix) + if newName, exists := componentMappings[componentName]; exists { + newRef := expectedPrefix + newName + *ref.Reference = references.Reference(newRef) + } + } + + return nil +} + +// joinTags joins tags from source document, appending unless there are name conflicts +func joinTags(result, src *OpenAPI) { + if src.Tags == nil { + return + } + + // Create a map of existing tag names for quick lookup + existingTagNames := make(map[string]bool) + for _, tag := range result.Tags { + if tag != nil { + existingTagNames[tag.Name] = true + } + } + + // Append tags that don't conflict + for _, tag := range src.Tags { + if tag != nil && !existingTagNames[tag.Name] { + result.Tags = append(result.Tags, tag) + existingTagNames[tag.Name] = true + } + } +} + +// joinServersAndSecurity handles smart conflict resolution for servers and security +func joinServersAndSecurity(result, src *OpenAPI) { + // Check if servers are identical (by hash) + serversConflict := !areServersIdentical(result.Servers, src.Servers) + + // Check if security is identical (by hash) + securityConflict := !areSecurityIdentical(result.Security, src.Security) + + // If there are conflicts, we need to push them down to operation level + if serversConflict || securityConflict { + // Apply source document's servers/security to its operations before joining + applyGlobalServersSecurityToOperations(src, serversConflict, securityConflict) + + // Apply result document's servers/security to its operations if needed + applyGlobalServersSecurityToOperations(result, serversConflict, securityConflict) + + // Clear conflicting global settings from result document + if serversConflict { + result.Servers = nil + } + if securityConflict { + result.Security = nil + } + } + // If no conflicts, keep result document's global servers/security as-is +} + +// areServersIdentical checks if two server slices are identical by hash +func areServersIdentical(servers1, servers2 []*Server) bool { + // If one is empty and the other is not, they can be merged without conflict + // (empty servers can inherit from non-empty - just different base URLs) + if len(servers1) == 0 || len(servers2) == 0 { + return true + } + + if len(servers1) != len(servers2) { + return false + } + + // Hash both server slices and compare + hash1 := hashing.Hash(servers1) + hash2 := hashing.Hash(servers2) + + return hash1 == hash2 +} + +// areSecurityIdentical checks if two security requirement slices are identical by hash +func areSecurityIdentical(security1, security2 []*SecurityRequirement) bool { + // Security is different: empty security means "no auth required" + // vs non-empty means "auth required" - these are fundamentally different + // and must be treated as conflicts + if len(security1) != len(security2) { + return false + } + + // Hash both security slices and compare + hash1 := hashing.Hash(security1) + hash2 := hashing.Hash(security2) + + return hash1 == hash2 +} + +// applyGlobalServersSecurityToOperations pushes global servers/security down to operation level +func applyGlobalServersSecurityToOperations(doc *OpenAPI, applyServers, applySecurity bool) { + if doc.Paths == nil { + return + } + + // Walk through all operations and apply global settings + for _, pathItem := range doc.Paths.All() { + if pathItem == nil || pathItem.Object == nil { + continue + } + + pathItemObj := pathItem.Object + + // Apply to path-level servers if needed + if applyServers && len(doc.Servers) > 0 && len(pathItemObj.Servers) == 0 { + pathItemObj.Servers = make([]*Server, len(doc.Servers)) + copy(pathItemObj.Servers, doc.Servers) + } + + // Apply to each operation in the path item + for _, operation := range pathItemObj.All() { + if operation == nil { + continue + } + + // Apply servers to operation if it doesn't have any + if applyServers && len(doc.Servers) > 0 && len(operation.Servers) == 0 { + operation.Servers = make([]*Server, len(doc.Servers)) + copy(operation.Servers, doc.Servers) + } + + // Apply security to operation if it doesn't have any + if applySecurity && len(doc.Security) > 0 && len(operation.Security) == 0 { + operation.Security = make([]*SecurityRequirement, len(doc.Security)) + copy(operation.Security, doc.Security) + } + } + } +} diff --git a/openapi/join_test.go b/openapi/join_test.go new file mode 100644 index 0000000..9c56dfa --- /dev/null +++ b/openapi/join_test.go @@ -0,0 +1,341 @@ +package openapi_test + +import ( + "bytes" + "os" + "testing" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJoin_Counter_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the main document + mainFile, err := os.Open("testdata/join/main.yaml") + require.NoError(t, err) + defer mainFile.Close() + + mainDoc, validationErrs, err := openapi.Unmarshal(ctx, mainFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Main document should be valid") + + // Load the second document + secondFile, err := os.Open("testdata/join/second.yaml") + require.NoError(t, err) + defer secondFile.Close() + + secondDoc, validationErrs, err := openapi.Unmarshal(ctx, secondFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Second document should be valid") + + // Load the third document + thirdFile, err := os.Open("testdata/join/third.yaml") + require.NoError(t, err) + defer thirdFile.Close() + + thirdDoc, validationErrs, err := openapi.Unmarshal(ctx, thirdFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Third document should be valid") + + // Configure join options with counter strategy + documents := []openapi.JoinDocumentInfo{ + { + Document: secondDoc, + FilePath: "second.yaml", + }, + { + Document: thirdDoc, + FilePath: "third.yaml", + }, + } + + opts := openapi.JoinOptions{ + ConflictStrategy: openapi.JoinConflictCounter, + } + + // Join documents + err = openapi.Join(ctx, mainDoc, documents, opts) + require.NoError(t, err) + + // Marshal the joined document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, mainDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Save the current output for comparison + err = os.WriteFile("testdata/join/joined_counter_current.yaml", actualYAML, 0644) + require.NoError(t, err) + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/join/joined_counter_expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "Joined document with counter strategy should match expected output") +} + +func TestJoin_FilePath_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the main document + mainFile, err := os.Open("testdata/join/main.yaml") + require.NoError(t, err) + defer mainFile.Close() + + mainDoc, validationErrs, err := openapi.Unmarshal(ctx, mainFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Main document should be valid") + + // Load the second document + secondFile, err := os.Open("testdata/join/second.yaml") + require.NoError(t, err) + defer secondFile.Close() + + secondDoc, validationErrs, err := openapi.Unmarshal(ctx, secondFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Second document should be valid") + + // Load the third document + thirdFile, err := os.Open("testdata/join/third.yaml") + require.NoError(t, err) + defer thirdFile.Close() + + thirdDoc, validationErrs, err := openapi.Unmarshal(ctx, thirdFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Third document should be valid") + + // Configure join options with filepath strategy + documents := []openapi.JoinDocumentInfo{ + { + Document: secondDoc, + FilePath: "second.yaml", + }, + { + Document: thirdDoc, + FilePath: "third.yaml", + }, + } + + opts := openapi.JoinOptions{ + ConflictStrategy: openapi.JoinConflictFilePath, + } + + // Join documents + err = openapi.Join(ctx, mainDoc, documents, opts) + require.NoError(t, err) + + // Marshal the joined document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, mainDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Save the current output for comparison + err = os.WriteFile("testdata/join/joined_filepath_current.yaml", actualYAML, 0644) + require.NoError(t, err) + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/join/joined_filepath_expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "Joined document with filepath strategy should match expected output") +} + +func TestJoin_EmptyDocuments_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the main document + mainFile, err := os.Open("testdata/join/main.yaml") + require.NoError(t, err) + defer mainFile.Close() + + mainDoc, validationErrs, err := openapi.Unmarshal(ctx, mainFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Main document should be valid") + + // Store original values for comparison + originalTitle := mainDoc.Info.Title + originalVersion := mainDoc.Info.Version + + // Join with empty documents slice + documents := []openapi.JoinDocumentInfo{} + + opts := openapi.JoinOptions{ + ConflictStrategy: openapi.JoinConflictCounter, + } + + err = openapi.Join(ctx, mainDoc, documents, opts) + require.NoError(t, err) + + // Main document should remain unchanged + assert.Equal(t, originalTitle, mainDoc.Info.Title) + assert.Equal(t, originalVersion, mainDoc.Info.Version) +} + +func TestJoin_NilMainDocument_Error(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + documents := []openapi.JoinDocumentInfo{} + opts := openapi.JoinOptions{} + + err := openapi.Join(ctx, nil, documents, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "main document is nil") +} + +func TestJoin_NilDocumentInSlice_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the main document + mainFile, err := os.Open("testdata/join/main.yaml") + require.NoError(t, err) + defer mainFile.Close() + + mainDoc, validationErrs, err := openapi.Unmarshal(ctx, mainFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Main document should be valid") + + // Include nil document in slice + documents := []openapi.JoinDocumentInfo{ + { + Document: nil, + FilePath: "nil.yaml", + }, + } + + opts := openapi.JoinOptions{ + ConflictStrategy: openapi.JoinConflictCounter, + } + + // Should not error, just skip nil documents + err = openapi.Join(ctx, mainDoc, documents, opts) + assert.NoError(t, err) +} + +func TestJoin_NoFilePath_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the main document + mainFile, err := os.Open("testdata/join/main.yaml") + require.NoError(t, err) + defer mainFile.Close() + + mainDoc, validationErrs, err := openapi.Unmarshal(ctx, mainFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Main document should be valid") + + // Load the second document + secondFile, err := os.Open("testdata/join/second.yaml") + require.NoError(t, err) + defer secondFile.Close() + + secondDoc, validationErrs, err := openapi.Unmarshal(ctx, secondFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Second document should be valid") + + // Join documents without file path + documents := []openapi.JoinDocumentInfo{ + { + Document: secondDoc, + FilePath: "", // Empty file path + }, + } + + opts := openapi.JoinOptions{ + ConflictStrategy: openapi.JoinConflictCounter, + } + + err = openapi.Join(ctx, mainDoc, documents, opts) + require.NoError(t, err) + + // Verify original /users path exists + assert.True(t, mainDoc.Paths.Has("/users")) + + // Verify conflicting path uses fallback name + assert.True(t, mainDoc.Paths.Has("/users#document_0")) +} + +func TestJoin_ServersSecurityConflicts_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the main document + mainFile, err := os.Open("testdata/join/main.yaml") + require.NoError(t, err) + defer mainFile.Close() + + mainDoc, validationErrs, err := openapi.Unmarshal(ctx, mainFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Main document should be valid") + + // Load the conflict servers document + conflictServersFile, err := os.Open("testdata/join/conflict_servers.yaml") + require.NoError(t, err) + defer conflictServersFile.Close() + + conflictServersDoc, validationErrs, err := openapi.Unmarshal(ctx, conflictServersFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Conflict servers document should be valid") + + // Load the conflict security document + conflictSecurityFile, err := os.Open("testdata/join/conflict_security.yaml") + require.NoError(t, err) + defer conflictSecurityFile.Close() + + conflictSecurityDoc, validationErrs, err := openapi.Unmarshal(ctx, conflictSecurityFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Conflict security document should be valid") + + // Configure join options with counter strategy + documents := []openapi.JoinDocumentInfo{ + { + Document: conflictServersDoc, + FilePath: "conflict_servers.yaml", + }, + { + Document: conflictSecurityDoc, + FilePath: "conflict_security.yaml", + }, + } + + opts := openapi.JoinOptions{ + ConflictStrategy: openapi.JoinConflictCounter, + } + + err = openapi.Join(ctx, mainDoc, documents, opts) + require.NoError(t, err) + + // Marshal the result to YAML for comparison + var buf bytes.Buffer + err = openapi.Marshal(ctx, mainDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Save the current output for comparison + err = os.WriteFile("testdata/join/joined_conflicts_current.yaml", actualYAML, 0644) + require.NoError(t, err) + + // Read expected output + expectedBytes, err := os.ReadFile("testdata/join/joined_conflicts_expected.yaml") + require.NoError(t, err) + + assert.Equal(t, string(expectedBytes), string(actualYAML), "Joined document with server/security conflicts should match expected output") +} diff --git a/openapi/reference.go b/openapi/reference.go index 7853d7f..62b50f9 100644 --- a/openapi/reference.go +++ b/openapi/reference.go @@ -37,6 +37,132 @@ type ( ReferencedSecurityScheme = Reference[SecurityScheme, *SecurityScheme, *core.SecurityScheme] ) +// NewReferencedPathItemFromRef creates a new ReferencedPathItem from a reference. +func NewReferencedPathItemFromRef(ref *references.Reference) *ReferencedPathItem { + return &ReferencedPathItem{ + Reference: ref, + } +} + +// NewReferencedPathItemFromPathItem creates a new ReferencedPathItem from a PathItem. +func NewReferencedPathItemFromPathItem(pathItem *PathItem) *ReferencedPathItem { + return &ReferencedPathItem{ + Object: pathItem, + } +} + +// NewReferencedExampleFromRef creates a new ReferencedExample from a reference. +func NewReferencedExampleFromRef(ref *references.Reference) *ReferencedExample { + return &ReferencedExample{ + Reference: ref, + } +} + +// NewReferencedExampleFromExample creates a new ReferencedExample from an Example. +func NewReferencedExampleFromExample(example *Example) *ReferencedExample { + return &ReferencedExample{ + Object: example, + } +} + +// NewReferencedParameterFromRef creates a new ReferencedParameter from a reference. +func NewReferencedParameterFromRef(ref *references.Reference) *ReferencedParameter { + return &ReferencedParameter{ + Reference: ref, + } +} + +// NewReferencedParameterFromParameter creates a new ReferencedParameter from a Parameter. +func NewReferencedParameterFromParameter(parameter *Parameter) *ReferencedParameter { + return &ReferencedParameter{ + Object: parameter, + } +} + +// NewReferencedHeaderFromRef creates a new ReferencedHeader from a reference. +func NewReferencedHeaderFromRef(ref *references.Reference) *ReferencedHeader { + return &ReferencedHeader{ + Reference: ref, + } +} + +// NewReferencedHeaderFromHeader creates a new ReferencedHeader from a Header. +func NewReferencedHeaderFromHeader(header *Header) *ReferencedHeader { + return &ReferencedHeader{ + Object: header, + } +} + +// NewReferencedRequestBodyFromRef creates a new ReferencedRequestBody from a reference. +func NewReferencedRequestBodyFromRef(ref *references.Reference) *ReferencedRequestBody { + return &ReferencedRequestBody{ + Reference: ref, + } +} + +// NewReferencedRequestBodyFromRequestBody creates a new ReferencedRequestBody from a RequestBody. +func NewReferencedRequestBodyFromRequestBody(requestBody *RequestBody) *ReferencedRequestBody { + return &ReferencedRequestBody{ + Object: requestBody, + } +} + +// NewReferencedResponseFromRef creates a new ReferencedResponse from a reference. +func NewReferencedResponseFromRef(ref *references.Reference) *ReferencedResponse { + return &ReferencedResponse{ + Reference: ref, + } +} + +// NewReferencedResponseFromResponse creates a new ReferencedResponse from a Response. +func NewReferencedResponseFromResponse(response *Response) *ReferencedResponse { + return &ReferencedResponse{ + Object: response, + } +} + +// NewReferencedCallbackFromRef creates a new ReferencedCallback from a reference. +func NewReferencedCallbackFromRef(ref *references.Reference) *ReferencedCallback { + return &ReferencedCallback{ + Reference: ref, + } +} + +// NewReferencedCallbackFromCallback creates a new ReferencedCallback from a Callback. +func NewReferencedCallbackFromCallback(callback *Callback) *ReferencedCallback { + return &ReferencedCallback{ + Object: callback, + } +} + +// NewReferencedLinkFromRef creates a new ReferencedLink from a reference. +func NewReferencedLinkFromRef(ref *references.Reference) *ReferencedLink { + return &ReferencedLink{ + Reference: ref, + } +} + +// NewReferencedLinkFromLink creates a new ReferencedLink from a Link. +func NewReferencedLinkFromLink(link *Link) *ReferencedLink { + return &ReferencedLink{ + Object: link, + } +} + +// NewReferencedSecuritySchemeFromRef creates a new ReferencedSecurityScheme from a reference. +func NewReferencedSecuritySchemeFromRef(ref *references.Reference) *ReferencedSecurityScheme { + return &ReferencedSecurityScheme{ + Reference: ref, + } +} + +// NewReferencedSecuritySchemeFromSecurityScheme creates a new ReferencedSecurityScheme from a SecurityScheme. +func NewReferencedSecuritySchemeFromSecurityScheme(securityScheme *SecurityScheme) *ReferencedSecurityScheme { + return &ReferencedSecurityScheme{ + Object: securityScheme, + } +} + type ReferencedObject[T any] interface { IsReference() bool GetObject() *T diff --git a/openapi/responses.go b/openapi/responses.go index 6be6402..68f4494 100644 --- a/openapi/responses.go +++ b/openapi/responses.go @@ -26,9 +26,9 @@ type Responses struct { var _ interfaces.Model[core.Responses] = (*Responses)(nil) // NewResponses creates a new Responses instance with an initialized map. -func NewResponses() *Responses { +func NewResponses(elements ...*sequencedmap.Element[string, *ReferencedResponse]) *Responses { return &Responses{ - Map: *sequencedmap.New[string, *ReferencedResponse](), + Map: *sequencedmap.New(elements...), } } diff --git a/openapi/testdata/join/conflict_security.yaml b/openapi/testdata/join/conflict_security.yaml new file mode 100644 index 0000000..32b271e --- /dev/null +++ b/openapi/testdata/join/conflict_security.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Conflicting Security API + version: 1.0.0 + description: API with different security that should push to operation level +servers: + - url: https://main-api.example.com + description: Main server +security: + - BearerAuth: [] + - OAuth2: [read, write] +paths: + /admin: + get: + summary: Admin endpoint with different security requirements + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + message: + type: string +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + OAuth2: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + write: Write access diff --git a/openapi/testdata/join/conflict_servers.yaml b/openapi/testdata/join/conflict_servers.yaml new file mode 100644 index 0000000..cd26ba3 --- /dev/null +++ b/openapi/testdata/join/conflict_servers.yaml @@ -0,0 +1,37 @@ +openapi: 3.1.0 +info: + title: Conflicting Servers API + version: 1.0.0 + description: API with different servers that should push to operation level +servers: + - url: https://different-api.example.com + description: Different server that conflicts +security: + - ApiKeyAuth: [] +paths: + /orders: + get: + summary: Get orders with different server requirements + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Order" +components: + schemas: + Order: + type: object + properties: + id: + type: integer + status: + type: string + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key diff --git a/openapi/testdata/join/joined_conflicts_current.yaml b/openapi/testdata/join/joined_conflicts_current.yaml new file mode 100644 index 0000000..6460deb --- /dev/null +++ b/openapi/testdata/join/joined_conflicts_current.yaml @@ -0,0 +1,150 @@ +openapi: 3.1.0 +info: + title: Main API + version: 1.0.0 + description: Main API for joining tests +tags: + - name: users + description: User operations +paths: + /users: + get: + summary: Get users + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/User' + servers: + - url: https://main-api.example.com + description: Main server + security: + - ApiKeyAuth: [] + servers: + - url: https://main-api.example.com + description: Main server + /health: + get: + summary: Health check + responses: + '200': + description: OK + servers: + - url: https://main-api.example.com + description: Main server + security: + - ApiKeyAuth: [] + servers: + - url: https://main-api.example.com + description: Main server + /orders: + get: + summary: Get orders with different server requirements + servers: + - url: https://different-api.example.com + description: Different server that conflicts + security: + - ApiKeyAuth: [] + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' + servers: + - url: https://different-api.example.com + description: Different server that conflicts + /admin: + get: + summary: Admin endpoint with different security requirements + security: + - BearerAuth: [] + - OAuth2: + - read + - write + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + message: + type: string +webhooks: + userCreated: + post: + summary: User created webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: OK +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + Error: + type: object + properties: + code: + type: integer + message: + type: string + Order: + type: object + properties: + id: + type: integer + status: + type: string + responses: + NotFound: + description: The specified resource was not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + parameters: + limitParam: + name: limit + in: query + description: max records to return + schema: + type: integer + format: int32 + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + OAuth2: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + write: Write access diff --git a/openapi/testdata/join/joined_conflicts_expected.yaml b/openapi/testdata/join/joined_conflicts_expected.yaml new file mode 100644 index 0000000..6460deb --- /dev/null +++ b/openapi/testdata/join/joined_conflicts_expected.yaml @@ -0,0 +1,150 @@ +openapi: 3.1.0 +info: + title: Main API + version: 1.0.0 + description: Main API for joining tests +tags: + - name: users + description: User operations +paths: + /users: + get: + summary: Get users + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/User' + servers: + - url: https://main-api.example.com + description: Main server + security: + - ApiKeyAuth: [] + servers: + - url: https://main-api.example.com + description: Main server + /health: + get: + summary: Health check + responses: + '200': + description: OK + servers: + - url: https://main-api.example.com + description: Main server + security: + - ApiKeyAuth: [] + servers: + - url: https://main-api.example.com + description: Main server + /orders: + get: + summary: Get orders with different server requirements + servers: + - url: https://different-api.example.com + description: Different server that conflicts + security: + - ApiKeyAuth: [] + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' + servers: + - url: https://different-api.example.com + description: Different server that conflicts + /admin: + get: + summary: Admin endpoint with different security requirements + security: + - BearerAuth: [] + - OAuth2: + - read + - write + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + message: + type: string +webhooks: + userCreated: + post: + summary: User created webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: OK +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + Error: + type: object + properties: + code: + type: integer + message: + type: string + Order: + type: object + properties: + id: + type: integer + status: + type: string + responses: + NotFound: + description: The specified resource was not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + parameters: + limitParam: + name: limit + in: query + description: max records to return + schema: + type: integer + format: int32 + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + OAuth2: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + write: Write access diff --git a/openapi/testdata/join/joined_counter_current.yaml b/openapi/testdata/join/joined_counter_current.yaml new file mode 100644 index 0000000..bb7bb65 --- /dev/null +++ b/openapi/testdata/join/joined_counter_current.yaml @@ -0,0 +1,214 @@ +openapi: 3.1.0 +info: + title: Main API + version: 1.0.0 + description: Main API for joining tests +servers: + - url: https://main-api.example.com + description: Main server +security: + - ApiKeyAuth: [] +tags: + - name: users + description: User operations + - name: products + description: Product operations +paths: + /users: + get: + summary: Get users + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /health: + get: + summary: Health check + responses: + '200': + description: OK + /products: + get: + summary: Get products + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + /users#second: + post: + summary: Create user + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User_1' + responses: + "201": + description: Created + /orders: + get: + summary: Get orders + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Order' +webhooks: + userCreated: + post: + summary: User created webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: OK + productUpdated: + post: + summary: Product updated webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + responses: + "200": + description: OK +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + Error: + type: object + properties: + code: + type: integer + message: + type: string + Product: + type: object + properties: + id: + type: integer + name: + type: string + price: + type: number + format: float + User_1: + type: object + properties: + id: + type: string + username: + type: string + active: + type: boolean + Category: + type: object + properties: + id: + type: integer + name: + type: string + Order: + type: object + properties: + id: + type: integer + userId: + type: integer + productId: + type: integer + quantity: + type: integer + status: + type: string + enum: + - pending + - confirmed + - shipped + - delivered + responses: + NotFound: + description: The specified resource was not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound_1: + description: Resource not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + BadRequest: + description: Bad request + content: + application/json: + schema: + type: object + properties: + message: + type: string + Unauthorized: + description: Unauthorized access + content: + application/json: + schema: + type: object + properties: + error: + type: string + parameters: + limitParam: + name: limit + in: query + description: max records to return + schema: + type: integer + format: int32 + offsetParam: + name: offset + in: query + description: number of items to skip + schema: + type: integer + format: int32 + limitParam_1: + name: limit + in: query + description: maximum number of items to return + schema: + type: integer + format: int32 + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/openapi/testdata/join/joined_counter_expected.yaml b/openapi/testdata/join/joined_counter_expected.yaml new file mode 100644 index 0000000..bb7bb65 --- /dev/null +++ b/openapi/testdata/join/joined_counter_expected.yaml @@ -0,0 +1,214 @@ +openapi: 3.1.0 +info: + title: Main API + version: 1.0.0 + description: Main API for joining tests +servers: + - url: https://main-api.example.com + description: Main server +security: + - ApiKeyAuth: [] +tags: + - name: users + description: User operations + - name: products + description: Product operations +paths: + /users: + get: + summary: Get users + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /health: + get: + summary: Health check + responses: + '200': + description: OK + /products: + get: + summary: Get products + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + /users#second: + post: + summary: Create user + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User_1' + responses: + "201": + description: Created + /orders: + get: + summary: Get orders + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Order' +webhooks: + userCreated: + post: + summary: User created webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: OK + productUpdated: + post: + summary: Product updated webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + responses: + "200": + description: OK +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + Error: + type: object + properties: + code: + type: integer + message: + type: string + Product: + type: object + properties: + id: + type: integer + name: + type: string + price: + type: number + format: float + User_1: + type: object + properties: + id: + type: string + username: + type: string + active: + type: boolean + Category: + type: object + properties: + id: + type: integer + name: + type: string + Order: + type: object + properties: + id: + type: integer + userId: + type: integer + productId: + type: integer + quantity: + type: integer + status: + type: string + enum: + - pending + - confirmed + - shipped + - delivered + responses: + NotFound: + description: The specified resource was not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound_1: + description: Resource not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + BadRequest: + description: Bad request + content: + application/json: + schema: + type: object + properties: + message: + type: string + Unauthorized: + description: Unauthorized access + content: + application/json: + schema: + type: object + properties: + error: + type: string + parameters: + limitParam: + name: limit + in: query + description: max records to return + schema: + type: integer + format: int32 + offsetParam: + name: offset + in: query + description: number of items to skip + schema: + type: integer + format: int32 + limitParam_1: + name: limit + in: query + description: maximum number of items to return + schema: + type: integer + format: int32 + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/openapi/testdata/join/joined_filepath_current.yaml b/openapi/testdata/join/joined_filepath_current.yaml new file mode 100644 index 0000000..dbd5df3 --- /dev/null +++ b/openapi/testdata/join/joined_filepath_current.yaml @@ -0,0 +1,214 @@ +openapi: 3.1.0 +info: + title: Main API + version: 1.0.0 + description: Main API for joining tests +servers: + - url: https://main-api.example.com + description: Main server +security: + - ApiKeyAuth: [] +tags: + - name: users + description: User operations + - name: products + description: Product operations +paths: + /users: + get: + summary: Get users + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /health: + get: + summary: Health check + responses: + '200': + description: OK + /products: + get: + summary: Get products + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + /users#second: + post: + summary: Create user + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/second_yaml~User' + responses: + "201": + description: Created + /orders: + get: + summary: Get orders + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Order' +webhooks: + userCreated: + post: + summary: User created webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: OK + productUpdated: + post: + summary: Product updated webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + responses: + "200": + description: OK +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + Error: + type: object + properties: + code: + type: integer + message: + type: string + Product: + type: object + properties: + id: + type: integer + name: + type: string + price: + type: number + format: float + second_yaml~User: + type: object + properties: + id: + type: string + username: + type: string + active: + type: boolean + Category: + type: object + properties: + id: + type: integer + name: + type: string + Order: + type: object + properties: + id: + type: integer + userId: + type: integer + productId: + type: integer + quantity: + type: integer + status: + type: string + enum: + - pending + - confirmed + - shipped + - delivered + responses: + NotFound: + description: The specified resource was not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + second_yaml~NotFound: + description: Resource not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + BadRequest: + description: Bad request + content: + application/json: + schema: + type: object + properties: + message: + type: string + Unauthorized: + description: Unauthorized access + content: + application/json: + schema: + type: object + properties: + error: + type: string + parameters: + limitParam: + name: limit + in: query + description: max records to return + schema: + type: integer + format: int32 + offsetParam: + name: offset + in: query + description: number of items to skip + schema: + type: integer + format: int32 + second_yaml~limitParam: + name: limit + in: query + description: maximum number of items to return + schema: + type: integer + format: int32 + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/openapi/testdata/join/joined_filepath_expected.yaml b/openapi/testdata/join/joined_filepath_expected.yaml new file mode 100644 index 0000000..dbd5df3 --- /dev/null +++ b/openapi/testdata/join/joined_filepath_expected.yaml @@ -0,0 +1,214 @@ +openapi: 3.1.0 +info: + title: Main API + version: 1.0.0 + description: Main API for joining tests +servers: + - url: https://main-api.example.com + description: Main server +security: + - ApiKeyAuth: [] +tags: + - name: users + description: User operations + - name: products + description: Product operations +paths: + /users: + get: + summary: Get users + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /health: + get: + summary: Health check + responses: + '200': + description: OK + /products: + get: + summary: Get products + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + /users#second: + post: + summary: Create user + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/second_yaml~User' + responses: + "201": + description: Created + /orders: + get: + summary: Get orders + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Order' +webhooks: + userCreated: + post: + summary: User created webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: OK + productUpdated: + post: + summary: Product updated webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + responses: + "200": + description: OK +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + Error: + type: object + properties: + code: + type: integer + message: + type: string + Product: + type: object + properties: + id: + type: integer + name: + type: string + price: + type: number + format: float + second_yaml~User: + type: object + properties: + id: + type: string + username: + type: string + active: + type: boolean + Category: + type: object + properties: + id: + type: integer + name: + type: string + Order: + type: object + properties: + id: + type: integer + userId: + type: integer + productId: + type: integer + quantity: + type: integer + status: + type: string + enum: + - pending + - confirmed + - shipped + - delivered + responses: + NotFound: + description: The specified resource was not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + second_yaml~NotFound: + description: Resource not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + BadRequest: + description: Bad request + content: + application/json: + schema: + type: object + properties: + message: + type: string + Unauthorized: + description: Unauthorized access + content: + application/json: + schema: + type: object + properties: + error: + type: string + parameters: + limitParam: + name: limit + in: query + description: max records to return + schema: + type: integer + format: int32 + offsetParam: + name: offset + in: query + description: number of items to skip + schema: + type: integer + format: int32 + second_yaml~limitParam: + name: limit + in: query + description: maximum number of items to return + schema: + type: integer + format: int32 + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/openapi/testdata/join/main.yaml b/openapi/testdata/join/main.yaml new file mode 100644 index 0000000..32a6ed8 --- /dev/null +++ b/openapi/testdata/join/main.yaml @@ -0,0 +1,81 @@ +openapi: 3.1.0 +info: + title: Main API + version: 1.0.0 + description: Main API for joining tests +servers: + - url: https://main-api.example.com + description: Main server +security: + - ApiKeyAuth: [] +tags: + - name: users + description: User operations +paths: + /users: + get: + summary: Get users + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /health: + get: + summary: Health check + responses: + '200': + description: OK +webhooks: + userCreated: + post: + summary: User created webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: OK +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + Error: + type: object + properties: + code: + type: integer + message: + type: string + responses: + NotFound: + description: The specified resource was not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + parameters: + limitParam: + name: limit + in: query + description: max records to return + schema: + type: integer + format: int32 + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key diff --git a/openapi/testdata/join/second.yaml b/openapi/testdata/join/second.yaml new file mode 100644 index 0000000..807183d --- /dev/null +++ b/openapi/testdata/join/second.yaml @@ -0,0 +1,122 @@ +openapi: 3.1.0 +info: + title: Second API + version: 2.0.0 + description: Second API for joining tests +servers: + - url: https://main-api.example.com + description: Main server +security: + - ApiKeyAuth: [] +tags: + - name: products + description: Product operations +paths: + /products: + get: + summary: Get products + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + /users: + # This conflicts with main.yaml /users path + post: + summary: Create user + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '201': + description: Created +webhooks: + productUpdated: + post: + summary: Product updated webhook + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + responses: + '200': + description: OK +components: + schemas: + Product: + type: object + properties: + id: + type: integer + name: + type: string + price: + type: number + format: float + User: + # This conflicts with main.yaml User schema (different structure) + type: object + properties: + id: + type: string + username: + type: string + active: + type: boolean + Category: + type: object + properties: + id: + type: integer + name: + type: string + responses: + NotFound: + # This conflicts with main.yaml NotFound response (different description) + description: Resource not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + BadRequest: + description: Bad request + content: + application/json: + schema: + type: object + properties: + message: + type: string + parameters: + offsetParam: + name: offset + in: query + description: number of items to skip + schema: + type: integer + format: int32 + limitParam: + # This conflicts with main.yaml limitParam (different description) + name: limit + in: query + description: maximum number of items to return + schema: + type: integer + format: int32 + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key diff --git a/openapi/testdata/join/third.yaml b/openapi/testdata/join/third.yaml new file mode 100644 index 0000000..5cd9943 --- /dev/null +++ b/openapi/testdata/join/third.yaml @@ -0,0 +1,63 @@ +openapi: 3.1.0 +info: + title: Third API + version: 3.0.0 + description: Third API for joining tests +servers: + - url: https://main-api.example.com + description: Main server +security: + - ApiKeyAuth: [] +paths: + /orders: + get: + summary: Get orders + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Order' +components: + schemas: + Order: + type: object + properties: + id: + type: integer + userId: + type: integer + productId: + type: integer + quantity: + type: integer + status: + type: string + enum: [pending, confirmed, shipped, delivered] + User: + # This is identical to main.yaml User schema (should not create conflict) + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + responses: + Unauthorized: + description: Unauthorized access + content: + application/json: + schema: + type: object + properties: + error: + type: string + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key