Skip to content

Commit 2b33c13

Browse files
committed
feat: Add import-dir command for bulk importing API specifications
- Introduced a new command `import-dir` to allow users to import multiple API specification files from a specified directory. - Implemented functionality to scan for supported file types (.yaml, .yml, .json, .xml) and handle recursive imports. - Added comprehensive unit tests to ensure the reliability of the import process. - Updated documentation to include usage examples and details about the new command. This feature enhances the CLI's capabilities for managing API specifications efficiently.
1 parent 3eeb662 commit 2b33c13

File tree

6 files changed

+2346
-0
lines changed

6 files changed

+2346
-0
lines changed

cmd/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func NewCommad() *cobra.Command {
3939
}
4040

4141
command.AddCommand(NewImportCommand(&clientOpts))
42+
command.AddCommand(NewImportDirCommand(&clientOpts))
4243
command.AddCommand(NewVersionCommand())
4344
command.AddCommand(NewTestCommand())
4445
command.AddCommand(NewImportURLCommand())

cmd/importDir.go

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
/*
2+
* Copyright The Microcks Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package cmd
17+
18+
import (
19+
"fmt"
20+
"os"
21+
"path/filepath"
22+
"strings"
23+
24+
"github.com/microcks/microcks-cli/pkg/config"
25+
"github.com/microcks/microcks-cli/pkg/connectors"
26+
"github.com/spf13/cobra"
27+
)
28+
29+
// MicrocksClient interface for dependency injection
30+
type MicrocksClient interface {
31+
UploadArtifact(file string, main bool) (string, error)
32+
}
33+
34+
type FileType struct {
35+
Extension string
36+
IsPrimary bool
37+
}
38+
39+
type ImportResult struct {
40+
TotalFiles int
41+
SuccessCount int
42+
FailedCount int
43+
SuccessFiles []string
44+
FailedFiles []string
45+
Errors []string
46+
}
47+
48+
type ImportConfig struct {
49+
Recursive bool
50+
Pattern string
51+
Verbose bool
52+
}
53+
54+
type FileSystem interface {
55+
Stat(path string) (os.FileInfo, error)
56+
Walk(root string, walkFn filepath.WalkFunc) error
57+
ReadDir(name string) ([]os.DirEntry, error)
58+
}
59+
60+
type RealFileSystem struct{}
61+
62+
func (fs *RealFileSystem) Stat(path string) (os.FileInfo, error) {
63+
return os.Stat(path)
64+
}
65+
66+
func (fs *RealFileSystem) Walk(root string, walkFn filepath.WalkFunc) error {
67+
return filepath.Walk(root, walkFn)
68+
}
69+
70+
func (fs *RealFileSystem) ReadDir(name string) ([]os.DirEntry, error) {
71+
return os.ReadDir(name)
72+
}
73+
74+
var supportedExtensions = map[string]bool{
75+
".yaml": true,
76+
".yml": true,
77+
".json": true,
78+
".xml": true,
79+
}
80+
81+
type ImportError struct {
82+
File string
83+
Err error
84+
}
85+
86+
type ValidationError struct {
87+
Message string
88+
}
89+
90+
func (e ImportError) Error() string {
91+
return fmt.Sprintf("failed to import %s: %v", e.File, e.Err)
92+
}
93+
94+
func (e ValidationError) Error() string {
95+
return e.Message
96+
}
97+
98+
func NewImportDirCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command {
99+
var (
100+
recursive bool
101+
pattern string
102+
verbose bool
103+
)
104+
105+
var importDirCmd = &cobra.Command{
106+
Use: "import-dir",
107+
Short: "Import API artifacts from a directory",
108+
Long: `Import API artifacts from a directory recursively.
109+
110+
This command scans a directory for API specification files and imports them into Microcks.
111+
Supported file types: .yaml, .yml, .json, .xml
112+
113+
Examples:
114+
microcks import-dir ./api-specs
115+
microcks import-dir ./api-specs --recursive
116+
microcks import-dir ./api-specs --pattern "*.yaml"
117+
microcks import-dir ./api-specs --recursive --pattern "openapi.*"`,
118+
Run: func(cmd *cobra.Command, args []string) {
119+
if len(args) == 0 {
120+
fmt.Println("import-dir command requires a directory path")
121+
cmd.HelpFunc()(cmd, args)
122+
os.Exit(1)
123+
}
124+
125+
dirPath := args[0]
126+
127+
config.InsecureTLS = globalClientOpts.InsecureTLS
128+
config.CaCertPaths = globalClientOpts.CaCertPaths
129+
config.Verbose = globalClientOpts.Verbose
130+
131+
localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath)
132+
if err != nil {
133+
fmt.Println(err)
134+
return
135+
}
136+
137+
if localConfig == nil {
138+
fmt.Println("Please login to perform operation...")
139+
return
140+
}
141+
142+
if globalClientOpts.Context == "" {
143+
globalClientOpts.Context = localConfig.CurrentContext
144+
}
145+
146+
// Create client
147+
mc, err := connectors.NewClient(*globalClientOpts)
148+
if err != nil {
149+
fmt.Printf("error %v", err)
150+
return
151+
}
152+
153+
// Set up business logic dependencies
154+
fs := &RealFileSystem{}
155+
importConfig := ImportConfig{
156+
Recursive: recursive,
157+
Pattern: pattern,
158+
Verbose: verbose,
159+
}
160+
161+
// Execute business logic
162+
result, err := ImportDirectory(mc, fs, dirPath, importConfig)
163+
if err != nil {
164+
if validationErr, ok := err.(*ValidationError); ok {
165+
fmt.Println(validationErr.Message)
166+
return
167+
}
168+
fmt.Printf("Error: %v\n", err)
169+
os.Exit(1)
170+
}
171+
172+
// Display results
173+
if verbose {
174+
fmt.Printf("Found %d specification files to import...\n", result.TotalFiles)
175+
for i, file := range result.SuccessFiles {
176+
fmt.Printf("[%d/%d] ✓ Imported: %s\n", i+1, result.TotalFiles, file)
177+
}
178+
for i, file := range result.FailedFiles {
179+
errorMsg := "Unknown error"
180+
if i < len(result.Errors) {
181+
errorMsg = result.Errors[i]
182+
}
183+
fmt.Printf("✗ Failed: %s - %s\n", file, errorMsg)
184+
}
185+
} else {
186+
for _, file := range result.SuccessFiles {
187+
fmt.Printf("✓ Imported: %s\n", file)
188+
}
189+
for i, file := range result.FailedFiles {
190+
errorMsg := "Unknown error"
191+
if i < len(result.Errors) {
192+
errorMsg = result.Errors[i]
193+
}
194+
fmt.Printf("✗ Failed: %s - %s\n", file, errorMsg)
195+
}
196+
}
197+
198+
fmt.Printf("\nImport completed: %d/%d files imported successfully\n", result.SuccessCount, result.TotalFiles)
199+
},
200+
}
201+
202+
importDirCmd.Flags().BoolVar(&recursive, "recursive", false, "Scan subdirectories recursively")
203+
importDirCmd.Flags().StringVar(&pattern, "pattern", "", "File pattern to match (e.g., '*.yaml', 'openapi.*')")
204+
importDirCmd.Flags().BoolVar(&verbose, "verbose", false, "Show detailed progress")
205+
206+
return importDirCmd
207+
}
208+
209+
func ImportDirectory(client MicrocksClient, fs FileSystem, dirPath string, config ImportConfig) (ImportResult, error) {
210+
if err := validateDirectory(fs, dirPath); err != nil {
211+
return ImportResult{}, err
212+
}
213+
214+
files, err := findSpecificationFiles(fs, dirPath, config.Recursive, config.Pattern)
215+
if err != nil {
216+
return ImportResult{}, fmt.Errorf("error scanning directory: %w", err)
217+
}
218+
219+
if len(files) == 0 {
220+
return ImportResult{}, &ValidationError{Message: fmt.Sprintf("no specification files found in directory: %s", dirPath)}
221+
}
222+
223+
result := ImportResult{
224+
TotalFiles: len(files),
225+
SuccessFiles: make([]string, 0, len(files)),
226+
FailedFiles: make([]string, 0, len(files)),
227+
Errors: make([]string, 0, len(files)),
228+
}
229+
230+
for _, file := range files {
231+
fileType := detectFileType(file)
232+
233+
_, err := client.UploadArtifact(file, fileType.IsPrimary)
234+
if err != nil {
235+
result.FailedCount++
236+
result.FailedFiles = append(result.FailedFiles, file)
237+
result.Errors = append(result.Errors, fmt.Sprintf("error importing %s: %v", file, err))
238+
continue
239+
}
240+
241+
result.SuccessCount++
242+
result.SuccessFiles = append(result.SuccessFiles, file)
243+
}
244+
245+
return result, nil
246+
}
247+
248+
// validateDirectory checks if the directory exists and is accessible
249+
func validateDirectory(fs FileSystem, dirPath string) error {
250+
info, err := fs.Stat(dirPath)
251+
if err != nil {
252+
if os.IsNotExist(err) {
253+
return &ValidationError{Message: fmt.Sprintf("directory does not exist: %s", dirPath)}
254+
}
255+
return fmt.Errorf("error accessing directory %s: %w", dirPath, err)
256+
}
257+
258+
if !info.IsDir() {
259+
return &ValidationError{Message: fmt.Sprintf("path is not a directory: %s", dirPath)}
260+
}
261+
262+
return nil
263+
}
264+
265+
func findSpecificationFiles(fs FileSystem, dirPath string, recursive bool, pattern string) ([]string, error) {
266+
var files []string
267+
268+
walkFunc := func(path string, info os.FileInfo, err error) error {
269+
if err != nil {
270+
return err
271+
}
272+
273+
if info.IsDir() && !recursive && path != dirPath {
274+
return filepath.SkipDir
275+
}
276+
277+
if info.IsDir() {
278+
return nil
279+
}
280+
281+
ext := strings.ToLower(filepath.Ext(path))
282+
if !supportedExtensions[ext] {
283+
return nil
284+
}
285+
286+
if pattern != "" {
287+
matched, err := filepath.Match(pattern, filepath.Base(path))
288+
if err != nil {
289+
return err
290+
}
291+
if !matched {
292+
return nil
293+
}
294+
}
295+
296+
files = append(files, path)
297+
return nil
298+
}
299+
300+
err := fs.Walk(dirPath, walkFunc)
301+
return files, err
302+
}
303+
304+
func detectFileType(filePath string) FileType {
305+
fileName := strings.ToLower(filepath.Base(filePath))
306+
ext := filepath.Ext(filePath)
307+
308+
// Default to primary for most files
309+
isPrimary := true
310+
311+
if strings.Contains(fileName, "postman") || strings.Contains(fileName, "collection") {
312+
isPrimary = false
313+
}
314+
315+
// If there's an OpenAPI file, prefer it as primary
316+
if strings.Contains(fileName, "openapi") || strings.Contains(fileName, "swagger") {
317+
isPrimary = true
318+
}
319+
320+
return FileType{
321+
Extension: ext,
322+
IsPrimary: isPrimary,
323+
}
324+
}

0 commit comments

Comments
 (0)