├── .gitignore ├── go.mod ├── main.go ├── test_assets ├── swagger2.yaml └── swagger1.yaml ├── LICENSE ├── go.sum ├── README.md └── helpers ├── merger.go └── merger_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | result.yaml 2 | result.json -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/g3co/go-swagger-merger 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/ghodss/yaml v1.0.0 7 | github.com/stretchr/testify v1.10.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v2 v2.3.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/g3co/go-swagger-merger/helpers" 7 | ) 8 | 9 | func main() { 10 | merger := helpers.NewMerger() 11 | 12 | var outputFileName string 13 | 14 | flag.StringVar(&outputFileName, "o", "swag.yaml", "") 15 | flag.Parse() 16 | 17 | for _, f := range flag.Args() { 18 | err := merger.AddFile(f) 19 | if err != nil { 20 | panic(err) 21 | } 22 | } 23 | 24 | err := merger.Save(outputFileName) 25 | if err != nil { 26 | panic(err) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test_assets/swagger2.yaml: -------------------------------------------------------------------------------- 1 | info: 2 | title: Swagger2 3 | version: 1.0.0 4 | consumes: 5 | - application/json 6 | produces: 7 | - application/json 8 | swagger: '2.0' 9 | securityDefinitions: 10 | keystone: 11 | description: Swagger Merger Test 12 | type: apiKey 13 | in: header 14 | name: x-auth-token 15 | 16 | security: [] 17 | 18 | paths: 19 | /api/v1/clusters/: 20 | post: 21 | operationId: CreateClusterSwagger2 22 | summary: Create a cluster 23 | responses: 24 | 200: 25 | description: OK 26 | schema: 27 | $ref: '#/definitions/Cluster' 28 | parameters: 29 | - name: body 30 | in: body 31 | required: true 32 | schema: 33 | $ref: '#/definitions/Cluster' 34 | security: 35 | - keystone: [] 36 | 37 | definitions: 38 | Cluster: 39 | type: object 40 | properties: 41 | name: 42 | description: name of the cluster 43 | type: string -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 g3co 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= 4 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 8 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 12 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧬 Swagger Merger 2 | 3 | A simple CLI tool to merge multiple Swagger (OpenAPI) YAML and JSON files into a single spec. 4 | 5 | ### 🚀 Features 6 | 7 | * Merge two or more Swagger files into one 8 | * Supports overwrite logic based on file order 9 | * Ideal for modular API definitions 10 | 11 | 12 | ### 🛠️ Installation 13 | 14 | Make sure you have [Go installed](https://golang.org/doc/install), then run: 15 | 16 | ```bash 17 | go install github.com/g3co/go-swagger-merger 18 | ``` 19 | 20 | #### Check twice if Go's binary is included in PATH: 21 | A. Linux/MacOS 22 | 23 | Find ~/.zshrc (MacOS) or ~/.bashrc (Linux) and add following: 24 | ``` 25 | export PATH="$HOME/go/bin:$PATH" 26 | ``` 27 | B. Windows 28 | 29 | Although Go installer on Windows automatically adds itself to PATH, in case `go install` is unresolved, add following to Environment Variables (System): 30 | ``` 31 | C:\Program Files\Go\bin 32 | ``` 33 | 34 | ### 📦 Usage 35 | 36 | Merge two or more Swagger files into one: 37 | 38 | ```bash 39 | go-swagger-merger -o /data/swagger.yaml /data/swagger1.yaml /data/swagger2.json 40 | ``` 41 | 42 | You can add as many input files as needed — supported formats include `.yaml`, `.yml`, and `.json`: 43 | 44 | ```bash 45 | go-swagger-merger -o /data/swagger.json /data/swagger1.yaml /data/swagger2.yaml /data/swagger3.yaml 46 | ``` 47 | 48 | 49 | > ⚠️ **Note:** File order matters. Later files will overwrite conflicting fields from earlier ones. 50 | 51 | 52 | ### 🧪 Quick Test 53 | 54 | Test assets are available in the repository to quickly try out the merger 55 | 56 | -------------------------------------------------------------------------------- /test_assets/swagger1.yaml: -------------------------------------------------------------------------------- 1 | info: 2 | title: Swagger1 3 | version: 1.0.0 4 | consumes: 5 | - application/json 6 | produces: 7 | - application/json 8 | swagger: '2.0' 9 | securityDefinitions: 10 | keystone: 11 | description: Swagger Merger Test 12 | type: apiKey 13 | in: header 14 | name: x-auth-token 15 | 16 | security: [] 17 | 18 | paths: 19 | /api/v1/servers/: 20 | get: 21 | operationId: ListServers 22 | summary: List available servers 23 | responses: 24 | 200: 25 | description: OK 26 | schema: 27 | type: array 28 | items: 29 | $ref: '#/definitions/Server' 30 | security: 31 | - keystone: [] 32 | post: 33 | operationId: CreateServer 34 | summary: Create a server 35 | responses: 36 | 200: 37 | description: OK 38 | schema: 39 | $ref: '#/definitions/Server' 40 | parameters: 41 | - name: body 42 | in: body 43 | required: true 44 | schema: 45 | $ref: '#/definitions/Server' 46 | security: 47 | - keystone: [] 48 | /api/v1/clusters/: 49 | get: 50 | operationId: ListClusters 51 | summary: List available clusters 52 | responses: 53 | 200: 54 | description: OK 55 | schema: 56 | type: array 57 | items: 58 | $ref: '#/definitions/Cluster' 59 | security: 60 | - keystone: [] 61 | 62 | definitions: 63 | Cluster: 64 | type: object 65 | properties: 66 | name: 67 | description: name of the cluster 68 | type: string 69 | Server: 70 | properties: 71 | name: 72 | description: name of the server 73 | type: string -------------------------------------------------------------------------------- /helpers/merger.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/ghodss/yaml" 11 | ) 12 | 13 | type Merger struct { 14 | Swagger map[string]any 15 | } 16 | 17 | func NewMerger() *Merger { 18 | merger := new(Merger) 19 | merger.Swagger = map[string]any{} 20 | return merger 21 | } 22 | 23 | func (m *Merger) AddFile(file string) error { 24 | f, err := os.Open(file) 25 | if err != nil { 26 | return err 27 | } 28 | defer f.Close() 29 | 30 | content, err := io.ReadAll(f) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | var swaggerMap any 36 | 37 | fileExt := filepath.Ext(file) 38 | switch fileExt { 39 | case ".yaml", ".yml": 40 | if err = yaml.Unmarshal(content, &swaggerMap); err != nil { 41 | return err 42 | } 43 | case ".json": 44 | if err = json.Unmarshal(content, &swaggerMap); err != nil { 45 | return err 46 | } 47 | default: 48 | return fmt.Errorf("unsupported file extension: %s", fileExt) 49 | } 50 | 51 | merge(m.Swagger, swaggerMap.(map[string]any)) 52 | 53 | return nil 54 | } 55 | 56 | func merge(a, b map[string]any) { 57 | if a == nil { 58 | return 59 | } 60 | 61 | for key, item := range b { 62 | if i, ok := item.(map[string]any); ok { 63 | if _, ok := a[key]; ok { 64 | merge(a[key].(map[string]any), i) 65 | } else { 66 | a[key] = i 67 | } 68 | } else { 69 | a[key] = item 70 | } 71 | } 72 | } 73 | 74 | func (m *Merger) Save(fileName string) error { 75 | res := []byte{} 76 | var err error 77 | 78 | fileExt := filepath.Ext(fileName) 79 | switch fileExt { 80 | case ".yaml", ".yml": 81 | if res, err = yaml.Marshal(m.Swagger); err != nil { 82 | return err 83 | } 84 | case ".json": 85 | if res, err = json.Marshal(m.Swagger); err != nil { 86 | return err 87 | } 88 | default: 89 | return fmt.Errorf("unsupported file extension: %s", fileExt) 90 | } 91 | 92 | f, err := os.Create(fileName) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | defer f.Close() 98 | 99 | if _, err = f.Write(res); err != nil { 100 | return err 101 | } 102 | 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /helpers/merger_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewMerger(t *testing.T) { 11 | merger := NewMerger() 12 | assert.NotNil(t, merger) 13 | assert.NotNil(t, merger.Swagger) 14 | } 15 | 16 | func TestMerger_AddFileYAML(t *testing.T) { 17 | merger := NewMerger() 18 | 19 | // Test non-existent file 20 | err := merger.AddFile("nonexistent.yaml") 21 | assert.Error(t, err) 22 | 23 | // Create a test YAML file 24 | content := []byte(` 25 | swagger: "2.0" 26 | info: 27 | title: Test API 28 | paths: 29 | /test: 30 | get: 31 | summary: Test endpoint 32 | `) 33 | 34 | err = os.WriteFile("test.yaml", content, 0644) 35 | assert.NoError(t, err) 36 | defer os.Remove("test.yaml") 37 | 38 | // Test valid file 39 | err = merger.AddFile("test.yaml") 40 | assert.NoError(t, err) 41 | 42 | // Verify merged content 43 | assert.Equal(t, "2.0", merger.Swagger["swagger"]) 44 | } 45 | 46 | func TestMerger_Save(t *testing.T) { 47 | merger := NewMerger() 48 | merger.Swagger["test"] = "value" 49 | 50 | err := merger.Save("test_output.yaml") 51 | assert.NoError(t, err) 52 | defer os.Remove("test_output.yaml") 53 | 54 | // Verify file was created 55 | _, err = os.Stat("test_output.yaml") 56 | assert.NoError(t, err) 57 | } 58 | 59 | func TestMerger_MergeMultipleFiles(t *testing.T) { 60 | merger := NewMerger() 61 | 62 | // Create first YAML file 63 | content1 := []byte(` 64 | swagger: "2.0" 65 | info: 66 | title: First API 67 | version: "1.0" 68 | paths: 69 | /test: 70 | get: 71 | summary: Test1 GET 72 | /test2: 73 | post: 74 | summary: Test1 POST 75 | `) 76 | 77 | err := os.WriteFile("test1.yaml", content1, 0644) 78 | assert.NoError(t, err) 79 | defer os.Remove("test1.yaml") 80 | 81 | // Create second YAML file with overlapping fields 82 | content2 := []byte(` 83 | swagger: "3.0" 84 | info: 85 | title: Second API 86 | version: "2.0" 87 | paths: 88 | /test: 89 | post: 90 | summary: Test2 POST, should be created, should not overwrite first file 91 | /test2: 92 | post: 93 | summary: Test2 POST, should overwrite first file 94 | `) 95 | 96 | err = os.WriteFile("test2.yaml", content2, 0644) 97 | assert.NoError(t, err) 98 | defer os.Remove("test2.yaml") 99 | 100 | // Create second JSON file with overlapping fields 101 | content3 := []byte(`{ 102 | "swagger": "3.0", 103 | "info": { 104 | "title": "Third API", 105 | "version": "3.0" 106 | }, 107 | "paths": { 108 | "/test": { 109 | "put": { 110 | "summary": "Test3 PUT, should be created" 111 | } 112 | } 113 | } 114 | }`) 115 | 116 | err = os.WriteFile("test3.json", content3, 0644) 117 | assert.NoError(t, err) 118 | defer os.Remove("test3.json") 119 | 120 | // Add first file 121 | err = merger.AddFile("test1.yaml") 122 | assert.NoError(t, err) 123 | 124 | // Verify first file content 125 | assert.Equal(t, "2.0", merger.Swagger["swagger"]) 126 | assert.Equal(t, "First API", merger.Swagger["info"].(map[string]any)["title"]) 127 | 128 | // Add second file 129 | err = merger.AddFile("test2.yaml") 130 | assert.NoError(t, err) 131 | 132 | err = merger.AddFile("test3.json") 133 | assert.NoError(t, err) 134 | 135 | // Verify second file overwrote fields 136 | assert.Equal(t, "3.0", merger.Swagger["swagger"]) 137 | assert.Equal(t, "Third API", merger.Swagger["info"].(map[string]any)["title"]) 138 | assert.Equal(t, "3.0", merger.Swagger["info"].(map[string]any)["version"]) 139 | 140 | // Verify paths were merged 141 | paths := merger.Swagger["paths"].(map[string]any) 142 | testPath := paths["/test2"].(map[string]any) 143 | post := testPath["post"].(map[string]any) 144 | assert.Equal(t, "Test2 POST, should overwrite first file", post["summary"]) 145 | 146 | testPath = paths["/test"].(map[string]any) 147 | get := testPath["get"].(map[string]any) 148 | assert.Equal(t, "Test1 GET", get["summary"]) 149 | 150 | testPath = paths["/test"].(map[string]any) 151 | post = testPath["post"].(map[string]any) 152 | assert.Equal(t, "Test2 POST, should be created, should not overwrite first file", post["summary"]) 153 | 154 | testPath = paths["/test"].(map[string]any) 155 | put := testPath["put"].(map[string]any) 156 | assert.Equal(t, "Test3 PUT, should be created", put["summary"]) 157 | } 158 | --------------------------------------------------------------------------------