Skip to content

Commit a553c70

Browse files
feat: add OpenAPI document joining with intelligent conflict resolution and CLI interface (#27)
1 parent 389a322 commit a553c70

17 files changed

+2990
-2
lines changed

openapi/cmd/join.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
8+
"github.com/speakeasy-api/openapi/openapi"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// joinWriteInPlace controls whether to write the joined document back to the main file
13+
var joinWriteInPlace bool
14+
15+
// joinStrategy controls the conflict resolution strategy for joined components
16+
var joinStrategy string
17+
18+
var joinCmd = &cobra.Command{
19+
Use: "join <main-file> <document1> [document2...] [output-file]",
20+
Short: "Join multiple OpenAPI documents into a single document",
21+
Long: `Join combines multiple OpenAPI documents into a single unified document with intelligent conflict resolution.
22+
23+
This command merges OpenAPI specifications by:
24+
• Combining all paths, components, and operations from multiple documents
25+
• Resolving naming conflicts using configurable strategies
26+
• Handling servers and security requirements intelligently
27+
• Preserving external references while joining documents
28+
• Maintaining document integrity and validation
29+
30+
The join operation supports two conflict resolution strategies:
31+
• counter: Uses counter-based suffixes like User_1, User_2 for conflicts
32+
• filepath: Uses file path-based naming like second_yaml~User
33+
34+
Smart conflict handling:
35+
• Components: Identical components are merged, conflicts are renamed
36+
• Operations: Path conflicts use fragment-based naming (/users~1)
37+
• Servers/Security: Conflicts push settings to operation level
38+
• Tags: Unique tags are appended, identical tags are preserved
39+
40+
Examples:
41+
# Join to stdout (pipe-friendly)
42+
openapi openapi join ./main.yaml ./api1.yaml ./api2.yaml
43+
44+
# Join to specific file
45+
openapi openapi join ./main.yaml ./api1.yaml ./api2.yaml ./joined.yaml
46+
47+
# Join in-place with counter strategy
48+
openapi openapi join -w --strategy counter ./main.yaml ./api1.yaml
49+
50+
# Join with filepath strategy (default)
51+
openapi openapi join --strategy filepath ./main.yaml ./api1.yaml ./joined.yaml`,
52+
Args: cobra.MinimumNArgs(2),
53+
RunE: runJoinCommand,
54+
}
55+
56+
func init() {
57+
joinCmd.Flags().BoolVarP(&joinWriteInPlace, "write", "w", false, "Write joined document back to main file")
58+
joinCmd.Flags().StringVar(&joinStrategy, "strategy", "counter", "Conflict resolution strategy (counter|filepath)")
59+
}
60+
61+
func runJoinCommand(cmd *cobra.Command, args []string) error {
62+
ctx := context.Background()
63+
64+
// Parse arguments - last arg might be output file if it doesn't exist as input
65+
mainFile := args[0]
66+
var documentFiles []string
67+
var outputFile string
68+
69+
// Determine if last argument is an output file (doesn't exist) or input file (exists)
70+
if len(args) >= 3 {
71+
lastArg := args[len(args)-1]
72+
if _, err := os.Stat(lastArg); os.IsNotExist(err) {
73+
// Last argument doesn't exist, treat as output file
74+
documentFiles = args[1 : len(args)-1]
75+
outputFile = lastArg
76+
} else {
77+
// All arguments are input files
78+
documentFiles = args[1:]
79+
}
80+
} else {
81+
// Only main file and one document file
82+
documentFiles = args[1:]
83+
}
84+
85+
// Validate strategy
86+
var strategy openapi.JoinConflictStrategy
87+
switch joinStrategy {
88+
case "counter":
89+
strategy = openapi.JoinConflictCounter
90+
case "filepath":
91+
strategy = openapi.JoinConflictFilePath
92+
default:
93+
return fmt.Errorf("invalid strategy: %s (must be 'counter' or 'filepath')", joinStrategy)
94+
}
95+
96+
// Create processor
97+
processor, err := NewOpenAPIProcessor(mainFile, outputFile, joinWriteInPlace)
98+
if err != nil {
99+
return err
100+
}
101+
102+
// Load main document
103+
mainDoc, validationErrors, err := processor.LoadDocument(ctx)
104+
if err != nil {
105+
return err
106+
}
107+
108+
// Report validation errors for main document
109+
processor.ReportValidationErrors(validationErrors)
110+
111+
// Load additional documents
112+
var documents []*openapi.OpenAPI
113+
var filePaths []string
114+
115+
for _, docFile := range documentFiles {
116+
// Create a temporary processor for each document to load it
117+
docProcessor, err := NewOpenAPIProcessor(docFile, "", false)
118+
if err != nil {
119+
return fmt.Errorf("failed to create processor for %s: %w", docFile, err)
120+
}
121+
122+
doc, docValidationErrors, err := docProcessor.LoadDocument(ctx)
123+
if err != nil {
124+
return fmt.Errorf("failed to load document %s: %w", docFile, err)
125+
}
126+
127+
// Report validation errors for this document
128+
if len(docValidationErrors) > 0 && !processor.WriteToStdout {
129+
fmt.Printf("⚠️ Found %d validation errors in %s:\n", len(docValidationErrors), docFile)
130+
for i, validationErr := range docValidationErrors {
131+
fmt.Printf(" %d. %s\n", i+1, validationErr.Error())
132+
}
133+
fmt.Println()
134+
}
135+
136+
documents = append(documents, doc)
137+
filePaths = append(filePaths, docFile)
138+
}
139+
140+
// Prepare join options
141+
opts := openapi.JoinOptions{
142+
ConflictStrategy: strategy,
143+
}
144+
145+
if strategy == openapi.JoinConflictFilePath {
146+
// Create document path mappings for filepath strategy
147+
opts.DocumentPaths = make(map[int]string)
148+
for i, path := range filePaths {
149+
opts.DocumentPaths[i] = path
150+
}
151+
}
152+
153+
// Prepare document info slice
154+
var documentInfos []openapi.JoinDocumentInfo
155+
for i, doc := range documents {
156+
docInfo := openapi.JoinDocumentInfo{
157+
Document: doc,
158+
}
159+
if i < len(filePaths) {
160+
docInfo.FilePath = filePaths[i]
161+
}
162+
documentInfos = append(documentInfos, docInfo)
163+
}
164+
165+
// Perform the join operation (modifies mainDoc in place)
166+
if err := openapi.Join(ctx, mainDoc, documentInfos, opts); err != nil {
167+
return fmt.Errorf("failed to join documents: %w", err)
168+
}
169+
170+
// Print success message
171+
processor.PrintSuccess(fmt.Sprintf("Successfully joined %d documents with %s strategy", len(documents)+1, joinStrategy))
172+
173+
// Write the joined document (mainDoc was modified in place)
174+
if err := processor.WriteDocument(ctx, mainDoc); err != nil {
175+
return err
176+
}
177+
178+
return nil
179+
}

openapi/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ func Apply(rootCmd *cobra.Command) {
88
rootCmd.AddCommand(upgradeCmd)
99
rootCmd.AddCommand(inlineCmd)
1010
rootCmd.AddCommand(bundleCmd)
11+
rootCmd.AddCommand(joinCmd)
1112
}

0 commit comments

Comments
 (0)