Skip to content

Commit 70f90a2

Browse files
feat: add response flattening feature to OpenAPI transformation
- Introduced a new flag to enable flattening of oneOf/anyOf/allOf schemas after pagination processing. - Implemented the struct and struct to manage flattening configurations and results. - Added and functions to handle the flattening logic across multiple files. - Enhanced the function to support dry-run mode for pagination and flattening. - Updated the configuration struct to include and modified the root command to accept the new flag. - Added detailed logging for flattening results, including processed files and flattened references. - Created unit tests to validate the flattening functionality for various scenarios, including single and multiple references.
1 parent 165e8f6 commit 70f90a2

File tree

6 files changed

+1192
-6
lines changed

6 files changed

+1192
-6
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ OpenMorph is a production-grade CLI and TUI tool for transforming OpenAPI vendor
3737
### Package Managers (Recommended)
3838

3939
#### Homebrew (macOS/Linux)
40+
4041
```bash
4142
# Add the tap
4243
brew tap developerkunal/openmorph
@@ -46,6 +47,7 @@ brew install openmorph
4647
```
4748

4849
#### Scoop (Windows)
50+
4951
```powershell
5052
# Add the bucket
5153
scoop bucket add openmorph https://github.com/developerkunal/scoop-openmorph
@@ -57,9 +59,11 @@ scoop install openmorph
5759
### From Source
5860

5961
#### Prerequisites
62+
6063
- Go 1.24 or later
6164

6265
#### Build from source
66+
6367
```bash
6468
# Clone the repository
6569
git clone https://github.com/developerkunal/OpenMorph.git
@@ -72,6 +76,7 @@ go build -o openmorph main.go
7276
```
7377

7478
#### Install from source
79+
7580
```bash
7681
# Build and install to GOPATH/bin
7782
make install
@@ -120,6 +125,8 @@ openmorph --input ./openapi --mapping x-foo=x-bar --exclude x-ignore
120125
openmorph --input ./openapi --mapping x-foo=x-bar --dry-run
121126
```
122127

128+
**Note:** In dry-run mode, transformations (pagination and response flattening) are previewed independently based on the original file. In actual execution, they are applied sequentially, so later steps may show different results. Use `--interactive` mode to see the exact cumulative effects of all transformations.
129+
123130
### Example: Interactive Review (TUI)
124131

125132
```sh
@@ -277,6 +284,7 @@ When pagination priority is configured, OpenMorph:
277284
This project uses automated release management with package managers support. See the [Auto-Release Guide](AUTO_RELEASE_GUIDE.md) for complete setup instructions.
278285

279286
Quick commands:
287+
280288
```bash
281289
# Validate setup
282290
make validate

cmd/root.go

Lines changed: 189 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ import (
1414
"github.com/spf13/cobra"
1515
)
1616

17+
const (
18+
colorReset = "\033[0m"
19+
colorBold = "\033[1m"
20+
colorGreen = "\033[32m"
21+
colorYellow = "\033[33m"
22+
colorCyan = "\033[36m"
23+
colorBlue = "\033[34m"
24+
colorPurple = "\033[35m"
25+
colorRed = "\033[31m"
26+
)
27+
1728
// version is set by GoReleaser at build time. Do not update manually.
1829
var version = "dev"
1930

@@ -48,6 +59,7 @@ var (
4859
noConfig bool
4960
interactive bool
5061
paginationPriorityStr string
62+
flattenResponses bool
5163
)
5264

5365
var rootCmd = &cobra.Command{
@@ -66,7 +78,7 @@ var rootCmd = &cobra.Command{
6678
fmt.Fprintln(os.Stderr, "Config error:", err)
6779
os.Exit(1)
6880
}
69-
// Merge CLI --exclude, --validate, and --backup with config
81+
// Merge CLI --exclude, --validate, --backup, and --flatten-responses with config
7082
if len(exclude) > 0 {
7183
cfg.Exclude = append(cfg.Exclude, exclude...)
7284
}
@@ -76,6 +88,9 @@ var rootCmd = &cobra.Command{
7688
if cmd.Flag("backup") != nil && cmd.Flag("backup").Changed {
7789
cfg.Backup = backup
7890
}
91+
if cmd.Flag("flatten-responses") != nil && cmd.Flag("flatten-responses").Changed {
92+
cfg.FlattenResponses = flattenResponses
93+
}
7994
if paginationPriorityStr != "" {
8095
// Parse comma-separated pagination priority
8196
priorities := strings.Split(paginationPriorityStr, ",")
@@ -90,6 +105,7 @@ var rootCmd = &cobra.Command{
90105
fmt.Printf(" \033[1;34mInput: \033[0m%s\n", cfg.Input)
91106
fmt.Printf(" \033[1;34mBackup: \033[0m%v\n", cfg.Backup)
92107
fmt.Printf(" \033[1;34mValidate:\033[0m %v\n", cfg.Validate)
108+
fmt.Printf(" \033[1;34mFlatten Responses:\033[0m %v\n", cfg.FlattenResponses)
93109
fmt.Printf(" \033[1;34mExclude: \033[0m%v\n", cfg.Exclude)
94110
if len(cfg.PaginationPriority) > 0 {
95111
fmt.Printf(" \033[1;34mPagination Priority:\033[0m %v\n", cfg.PaginationPriority)
@@ -242,6 +258,27 @@ var rootCmd = &cobra.Command{
242258
printPaginationResults(paginationResult)
243259
}
244260

261+
// Process response flattening if configured (for interactive mode)
262+
if cfg.FlattenResponses && len(actuallyChanged) > 0 {
263+
fmt.Printf("\033[1;36mProcessing response flattening...\033[0m\n")
264+
flattenOpts := transform.FlattenOptions{
265+
Options: transform.Options{
266+
Mappings: cfg.Mappings,
267+
Exclude: cfg.Exclude,
268+
DryRun: false,
269+
Backup: cfg.Backup,
270+
},
271+
FlattenResponses: cfg.FlattenResponses,
272+
}
273+
flattenResult, err := transform.ProcessFlatteningInDir(cfg.Input, flattenOpts)
274+
if err != nil {
275+
fmt.Fprintln(os.Stderr, "Response flattening error:", err)
276+
os.Exit(2)
277+
}
278+
279+
printFlattenResults(flattenResult)
280+
}
281+
245282
// Run validation if requested (for interactive mode)
246283
if cfg.Validate {
247284
if err := runSwaggerValidate(cfg.Input); err != nil {
@@ -268,8 +305,81 @@ var rootCmd = &cobra.Command{
268305
}
269306
fmt.Printf("Transformed files: %v\n", changed)
270307

271-
// Process pagination if priority is configured
272-
if len(cfg.PaginationPriority) > 0 {
308+
// In dry-run mode, show what would be changed for pagination and flattening
309+
if dryRun {
310+
fmt.Printf("\033[1;33m╭─────────────────────────────────────────────────────────────╮\033[0m\n")
311+
fmt.Printf("\033[1;33m│ DRY-RUN PREVIEW MODE │\033[0m\n")
312+
fmt.Printf("\033[1;33m╰─────────────────────────────────────────────────────────────╯\033[0m\n")
313+
fmt.Printf("\033[1;31m⚠️ IMPORTANT: Dry-run shows INDEPENDENT previews of each step.\033[0m\n")
314+
fmt.Printf("\033[1;31m In actual execution, steps are CUMULATIVE (each builds on the previous).\033[0m\n")
315+
fmt.Printf("\033[1;31m Flattening results will differ significantly in real execution!\033[0m\n\n")
316+
317+
if len(cfg.PaginationPriority) > 0 {
318+
fmt.Printf("\033[1;36m[STEP 1] Pagination changes with priority: %v\033[0m\n", cfg.PaginationPriority)
319+
dryRunPaginationOpts := transform.PaginationOptions{
320+
Options: transform.Options{
321+
Mappings: cfg.Mappings,
322+
Exclude: cfg.Exclude,
323+
DryRun: true, // Force dry-run for preview
324+
Backup: cfg.Backup,
325+
},
326+
PaginationPriority: cfg.PaginationPriority,
327+
}
328+
paginationResult, err := transform.ProcessPaginationInDir(cfg.Input, dryRunPaginationOpts)
329+
if err != nil {
330+
fmt.Fprintln(os.Stderr, "Pagination dry-run error:", err)
331+
} else {
332+
printPaginationResults(paginationResult)
333+
}
334+
fmt.Println()
335+
}
336+
if cfg.FlattenResponses {
337+
fmt.Printf("\033[1;36m[STEP 2] Response flattening changes\033[0m\n")
338+
fmt.Printf("\033[1;31m⚠️ CRITICAL: This preview operates on the ORIGINAL file.\033[0m\n")
339+
fmt.Printf("\033[1;31m Real execution will show SIGNIFICANTLY MORE changes\033[0m\n")
340+
fmt.Printf("\033[1;31m because pagination creates new schemas to flatten!\033[0m\n")
341+
dryRunFlattenOpts := transform.FlattenOptions{
342+
Options: transform.Options{
343+
Mappings: cfg.Mappings,
344+
Exclude: cfg.Exclude,
345+
DryRun: true, // Force dry-run for preview
346+
Backup: cfg.Backup,
347+
},
348+
FlattenResponses: cfg.FlattenResponses,
349+
}
350+
flattenResult, err := transform.ProcessFlatteningInDir(cfg.Input, dryRunFlattenOpts)
351+
if err != nil {
352+
fmt.Fprintln(os.Stderr, "Response flattening dry-run error:", err)
353+
} else {
354+
printFlattenResults(flattenResult)
355+
}
356+
fmt.Println()
357+
}
358+
if cfg.Validate {
359+
fmt.Printf("\033[1;36m[STEP 3] Validation\033[0m\n")
360+
fmt.Printf("\033[1;33m⏭️ Skipping validation in dry-run mode\033[0m\n\n")
361+
}
362+
363+
fmt.Printf("\033[1;36m╭─────────────────────────────────────────────────────────────╮\033[0m\n")
364+
fmt.Printf("\033[1;36m│ 💡 TIP: Use --interactive mode to see exact cumulative │\033[0m\n")
365+
fmt.Printf("\033[1;36m│ effects of all transformations applied sequentially. │\033[0m\n")
366+
fmt.Printf("\033[1;36m╰─────────────────────────────────────────────────────────────╯\033[0m\n")
367+
368+
fmt.Printf("\n\033[1;33m📊 DRY-RUN SUMMARY:\033[0m\n")
369+
fmt.Printf(" • Mapping changes: Applied to original file\n")
370+
if len(cfg.PaginationPriority) > 0 {
371+
fmt.Printf(" • Pagination changes: Based on original file state\n")
372+
}
373+
if cfg.FlattenResponses {
374+
fmt.Printf(" • Flattening changes: Based on original file (will be much more extensive in real execution)\n")
375+
}
376+
fmt.Printf("\n\033[1;32m✅ For accurate cumulative results, use:\033[0m\n")
377+
fmt.Printf(" • \033[1;36m--interactive\033[0m mode for step-by-step review\n")
378+
fmt.Printf(" • Run without \033[1;36m--dry-run\033[0m on a backup/test file\n")
379+
}
380+
381+
// Process pagination if priority is configured (skip in dry-run mode)
382+
if len(cfg.PaginationPriority) > 0 && !dryRun {
273383
fmt.Printf("\033[1;36mProcessing pagination with priority: %v\033[0m\n", cfg.PaginationPriority)
274384
paginationOpts := transform.PaginationOptions{
275385
Options: opts,
@@ -284,6 +394,22 @@ var rootCmd = &cobra.Command{
284394
printPaginationResults(paginationResult)
285395
}
286396

397+
// Process response flattening if configured (skip in dry-run mode)
398+
if cfg.FlattenResponses && !dryRun {
399+
fmt.Printf("\033[1;36mProcessing response flattening...\033[0m\n")
400+
flattenOpts := transform.FlattenOptions{
401+
Options: opts,
402+
FlattenResponses: cfg.FlattenResponses,
403+
}
404+
flattenResult, err := transform.ProcessFlatteningInDir(cfg.Input, flattenOpts)
405+
if err != nil {
406+
fmt.Fprintln(os.Stderr, "Response flattening error:", err)
407+
os.Exit(2)
408+
}
409+
410+
printFlattenResults(flattenResult)
411+
}
412+
287413
// Run validation if requested
288414
if cfg.Validate && !dryRun {
289415
if err := runSwaggerValidate(cfg.Input); err != nil {
@@ -299,13 +425,14 @@ func init() {
299425
rootCmd.PersistentFlags().StringVarP(&inputDir, "input", "i", "", "Directory containing OpenAPI specs (YAML/JSON)")
300426
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Mapping config file (.yaml or .json)")
301427
rootCmd.PersistentFlags().StringArrayVar(&inlineMaps, "map", nil, "Inline key mappings (from=to), repeatable")
302-
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Show what would change without writing files")
428+
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Show what would change without writing files (Note: multi-step transformations shown independently, use --interactive for cumulative preview)")
303429
rootCmd.PersistentFlags().BoolVar(&backup, "backup", false, "Save a .bak copy before overwriting")
304430
rootCmd.PersistentFlags().StringArrayVar(&exclude, "exclude", nil, "Keys to exclude from transformation (repeatable)")
305431
rootCmd.PersistentFlags().BoolVar(&validate, "validate", false, "Run swagger-cli validate after transforming")
306432
rootCmd.PersistentFlags().BoolVar(&interactive, "interactive", false, "Launch a TUI for interactive preview and approval")
307433
rootCmd.PersistentFlags().BoolVar(&noConfig, "no-config", false, "Ignore all config files and use only CLI flags")
308434
rootCmd.PersistentFlags().StringVar(&paginationPriorityStr, "pagination-priority", "", "Pagination strategy priority order (e.g., checkpoint,offset,page,cursor,none)")
435+
rootCmd.PersistentFlags().BoolVar(&flattenResponses, "flatten-responses", false, "Flatten oneOf/anyOf/allOf with single $ref after pagination processing")
309436
}
310437

311438
// Execute runs the root command.
@@ -387,6 +514,64 @@ func printPaginationResults(paginationResult *transform.PaginationResult) {
387514
}
388515
}
389516

517+
func printFlattenResults(flattenResult *transform.FlattenResult) {
518+
if flattenResult == nil {
519+
fmt.Printf(" %sNo flattening result to display%s\n", colorRed, colorReset)
520+
return
521+
}
522+
523+
fmt.Println("🛠️ Processing response flattening...")
524+
525+
if !flattenResult.Changed {
526+
fmt.Printf(" %sNo response flattening changes needed.%s\n", colorYellow, colorReset)
527+
return
528+
}
529+
530+
fmt.Printf("%s✅ Response flattening completed%s\n", colorGreen, colorReset)
531+
fmt.Printf(" 📄 Processed files: %s%d%s\n", colorGreen, len(flattenResult.ProcessedFiles), colorReset)
532+
533+
for file, refs := range flattenResult.FlattenedRefs {
534+
fmt.Printf("\n🔍 Flattened references in: %s%s%s\n", colorBold, file, colorReset)
535+
536+
var oneOfs, anyOfs, allOfs, remaps []string
537+
for _, ref := range refs {
538+
switch {
539+
case strings.Contains(ref, "oneOf"):
540+
oneOfs = append(oneOfs, ref)
541+
case strings.Contains(ref, "anyOf"):
542+
anyOfs = append(anyOfs, ref)
543+
case strings.Contains(ref, "allOf"):
544+
allOfs = append(allOfs, ref)
545+
default:
546+
remaps = append(remaps, ref)
547+
}
548+
}
549+
550+
printCategory := func(label string, color string, items []string) {
551+
if len(items) == 0 {
552+
return
553+
}
554+
fmt.Printf(" ── %s%s%s:\n", color, label, colorReset)
555+
for _, item := range items {
556+
parts := strings.SplitN(item, "->", 2)
557+
if len(parts) == 2 {
558+
fmt.Printf(" - %s%s%s\n %s→%s %s\n",
559+
colorBold, strings.TrimSpace(parts[0]), colorReset,
560+
colorGreen, colorReset, strings.TrimSpace(parts[1]),
561+
)
562+
} else {
563+
fmt.Printf(" - %s\n", item)
564+
}
565+
}
566+
}
567+
568+
printCategory("oneOf replacements", colorYellow, oneOfs)
569+
printCategory("anyOf replacements", colorCyan, anyOfs)
570+
printCategory("allOf replacements", colorPurple, allOfs)
571+
printCategory("$ref remappings", colorBlue, remaps)
572+
}
573+
}
574+
390575
// execCommand is a wrapper for exec.Command (for testability)
391576
var execCommand = func(name string, arg ...string) *exec.Cmd {
392577
return exec.Command(name, arg...)

internal/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type Config struct {
1414
Exclude []string `yaml:"exclude" json:"exclude"`
1515
Mappings map[string]string `yaml:"mappings" json:"mappings"`
1616
PaginationPriority []string `yaml:"pagination_priority" json:"pagination_priority"`
17+
FlattenResponses bool `yaml:"flatten_responses" json:"flatten_responses"`
1718
}
1819

1920
// LoadConfig loads config from file (YAML/JSON) and merges with inline flags. If noConfig is true, ignores all config files and uses only CLI flags.

0 commit comments

Comments
 (0)