From c162cc8b10658d7e653ebe9e4e24c611978d55b5 Mon Sep 17 00:00:00 2001 From: Yusuke Tsutsumi Date: Sat, 2 Nov 2024 12:37:00 -0700 Subject: [PATCH] feat: add config add/list/get To make it easier to operate on config files, adding utility commands to interface with the config object. --- cmd/aepcli/main.go | 158 +++++++++++++++++++++++++++++---- docs/userguide.md | 19 +++- internal/config/config.go | 86 ++++++++++++++++-- internal/config/config_test.go | 63 +++++++++++++ 4 files changed, 299 insertions(+), 27 deletions(-) create mode 100644 internal/config/config_test.go diff --git a/cmd/aepcli/main.go b/cmd/aepcli/main.go index e7c8a2c..4bbc69b 100644 --- a/cmd/aepcli/main.go +++ b/cmd/aepcli/main.go @@ -25,59 +25,77 @@ func main() { func aepcli(args []string) error { var logLevel string - var fileOrAlias string + var fileAliasOrCore string var additionalArgs []string + var headers []string + var pathPrefix string + var serverURL string + var configFileVar string var s *service.Service rootCmd := &cobra.Command{ Use: "aepcli [host or api alias] [resource or --help]", Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - fileOrAlias = args[0] + fileAliasOrCore = args[0] if len(args) > 1 { additionalArgs = args[1:] } }, } - var rawHeaders []string - var pathPrefix string - var serverURL string + configFile, err := config.DefaultConfigFile() + if err != nil { + return fmt.Errorf("unable to get default config file: %w", err) + } + rootCmd.Flags().SetInterspersed(false) // allow sub parsers to parse subsequent flags after the resource - rootCmd.PersistentFlags().StringArrayVar(&rawHeaders, "header", []string{}, "Specify headers in the format key=value") + rootCmd.PersistentFlags().StringArrayVar(&headers, "header", []string{}, "Specify headers in the format key=value") rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Set the logging level (debug, info, warn, error)") rootCmd.PersistentFlags().StringVar(&pathPrefix, "path-prefix", "", "Specify a path prefix that is prepended to all paths in the openapi schema. This will strip them when evaluating the resource hierarchy paths.") rootCmd.PersistentFlags().StringVar(&serverURL, "server-url", "", "Specify a URL to use for the server. If not specified, the first server URL in the OpenAPI definition will be used.") - + rootCmd.PersistentFlags().StringVar(&configFileVar, "config", "", "Path to config file") rootCmd.SetArgs(args) + if err := rootCmd.Execute(); err != nil { return err } + if configFileVar != "" { + configFile = configFileVar + } + if err := setLogLevel(logLevel); err != nil { return fmt.Errorf("unable to set log level: %w", err) } - c, err := config.ReadConfig() + c, err := config.ReadConfigFromFile(configFile) if err != nil { return fmt.Errorf("unable to read config: %v", err) } - if api, ok := c.APIs[fileOrAlias]; ok { + if fileAliasOrCore == "core" { + return handleCoreCommand(additionalArgs, configFile) + } + + if api, ok := c.APIs[fileAliasOrCore]; ok { cd, err := config.ConfigDir() if err != nil { - fmt.Println(err) - os.Exit(1) + return fmt.Errorf("unable to get config directory: %w", err) + } + if filepath.IsAbs(api.OpenAPIPath) || strings.HasPrefix(api.OpenAPIPath, "http") { + fileAliasOrCore = api.OpenAPIPath + } else { + fileAliasOrCore = filepath.Join(cd, api.OpenAPIPath) } - fileOrAlias = filepath.Join(cd, api.OpenAPIPath) if pathPrefix == "" { pathPrefix = api.PathPrefix } - rawHeaders = append(rawHeaders, api.Headers...) + headers = append(headers, api.Headers...) serverURL = api.ServerURL } - openapi, err := openapi.FetchOpenAPI(fileOrAlias) + openapi, err := openapi.FetchOpenAPI(fileAliasOrCore) if err != nil { return fmt.Errorf("unable to fetch openapi: %w", err) } @@ -86,12 +104,12 @@ func aepcli(args []string) error { return fmt.Errorf("unable to get service definition: %w", err) } - headers, err := parseHeaders(rawHeaders) + headersMap, err := parseHeaders(headers) if err != nil { return fmt.Errorf("unable to parse headers: %w", err) } - s = service.NewService(*serviceDefinition, headers) + s = service.NewService(*serviceDefinition, headersMap) result, err := s.ExecuteCommand(additionalArgs) if err != nil { @@ -130,3 +148,111 @@ func setLogLevel(levelAsString string) error { slog.SetLogLoggerLevel(level) return nil } + +func handleCoreCommand(additionalArgs []string, configFile string) error { + var openAPIPath string + var overwrite bool + var api config.API + var serverURL string + var headers []string + var pathPrefix string + + coreCmd := &cobra.Command{ + Use: "core", + Short: "Core API management commands", + } + + configCmd := &cobra.Command{ + Use: "config", + Short: "Manage core API configurations", + } + + addCmd := &cobra.Command{ + Use: "add [name]", + Short: "Add a new core API configuration", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + api = config.API{ + Name: args[0], + OpenAPIPath: openAPIPath, + ServerURL: serverURL, + Headers: headers, + PathPrefix: pathPrefix, + } + if err := config.WriteAPIWithName(configFile, api, overwrite); err != nil { + fmt.Printf("Error writing API config: %v\n", err) + os.Exit(1) + } + fmt.Printf("Core API configuration '%s' added successfully\n", args[0]) + }, + } + + addCmd.Flags().StringVar(&openAPIPath, "openapi-path", "", "Path to OpenAPI specification file") + addCmd.Flags().StringArrayVar(&headers, "header", []string{}, "Headers in format key=value") + addCmd.Flags().StringVar(&serverURL, "server-url", "", "Server URL") + addCmd.Flags().StringVar(&pathPrefix, "path-prefix", "", "Path prefix") + addCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Overwrite existing configuration") + + readCmd := &cobra.Command{ + Use: "get [name]", + Short: "Get an API configuration", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + cfg, err := config.ReadConfigFromFile(configFile) + if err != nil { + fmt.Printf("Error reading config file: %v\n", err) + os.Exit(1) + } + + api, exists := cfg.APIs[args[0]] + if !exists { + fmt.Printf("No API configuration found with name '%s'\n", args[0]) + os.Exit(1) + } + + fmt.Printf("Name: %s\n", api.Name) + fmt.Printf("OpenAPI Path: %s\n", api.OpenAPIPath) + fmt.Printf("Server URL: %s\n", api.ServerURL) + fmt.Printf("Headers: %v\n", api.Headers) + fmt.Printf("Path Prefix: %s\n", api.PathPrefix) + }, + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "List all API configurations", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + apis, err := config.ListAPIs(configFile) + if err != nil { + fmt.Printf("Error listing APIs: %v\n", err) + os.Exit(1) + } + + if len(apis) == 0 { + fmt.Println("No API configurations found") + return + } + + for _, api := range apis { + fmt.Printf("Name: %s\n", api.Name) + fmt.Printf("OpenAPI Path: %s\n", api.OpenAPIPath) + fmt.Printf("Server URL: %s\n", api.ServerURL) + fmt.Printf("Headers: %v\n", api.Headers) + fmt.Printf("Path Prefix: %s\n", api.PathPrefix) + fmt.Println() + } + }, + } + + configCmd.AddCommand(addCmd) + configCmd.AddCommand(readCmd) + configCmd.AddCommand(listCmd) + coreCmd.AddCommand(configCmd) + + coreCmd.SetArgs(additionalArgs) + if err := coreCmd.Execute(); err != nil { + return fmt.Errorf("error executing core command: %v", err) + } + return nil +} diff --git a/docs/userguide.md b/docs/userguide.md index 3d51d18..bbe5d71 100644 --- a/docs/userguide.md +++ b/docs/userguide.md @@ -103,8 +103,19 @@ headers = ["X-API-TOKEN=123", "X-API-CLIENT=aepcli"] ``` If you would like to use aepcli as your recommend command-line interface for -your API, you can provide a simple script to write the appropriate configuration -to your configuration file. +your API, you can provide a one-liner to add the configuration to your +configuration file: + +```bash +aepcli core config add bookstore --openapi-path=$HOME/workspace/aepc/example/bookstore/v1/bookstore_openapi.json +``` + +You can also list and read all of the configurations you have added: + +```bash +aepcli core config list +aepcli core config get bookstore +``` ### specifying resource parent ids @@ -167,6 +178,10 @@ lists are specified as a comma-separated list: aepcli bookstore book-edition create --book "peter-pan" --publisher "consistent-house" --tags "fantasy,childrens" ``` +### core commands + +See `aepcli core --help` for commands for aepcli (e.g. config) + ## OpenAPI Definitions ### OAS definitions supported diff --git a/internal/config/config.go b/internal/config/config.go index 6280218..2632f11 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,9 @@ package config import ( + "errors" "fmt" + "log/slog" "os" "path/filepath" @@ -20,19 +22,16 @@ type API struct { PathPrefix string } -// ReadConfig reads the configuration from the default configuration file. -func ReadConfig() (*Config, error) { - configDir, err := ConfigDir() - if err != nil { +func ReadConfigFromFile(file string) (*Config, error) { + // Check if file exists first + if _, err := os.Stat(file); os.IsNotExist(err) { + slog.Error("Config file does not exist", "file", file) return nil, err } - return ReadConfigFromFile(filepath.Join(configDir, "config.toml")) -} -func ReadConfigFromFile(filename string) (*Config, error) { var c Config - if _, err := toml.DecodeFile(filename, &c); err != nil { - return nil, fmt.Errorf("unable to decode config file at %v: %v", filename, err) + if _, err := toml.DecodeFile(file, &c); err != nil { + return nil, fmt.Errorf("unable to decode config file at %v: %v", file, err) } return &c, nil } @@ -44,3 +43,72 @@ func ConfigDir() (string, error) { } return filepath.Join(homeDir, ".config", "aepcli"), nil } + +// WriteAPIWithName writes a new API configuration to the specified config file. +func WriteAPIWithName(file string, api API, overwrite bool) error { + if api.Name == "" { + return errors.New("api name cannot be empty") + } + + // Read existing config + cfg, err := ReadConfigFromFile(file) + if err != nil { + // If file doesn't exist yet, initialize new config + if errors.Is(err, os.ErrNotExist) { + cfg = &Config{ + APIs: make(map[string]API), + } + } else { + return fmt.Errorf("failed to read existing config: %w", err) + } + } + + // Check if API already exists + if _, exists := cfg.APIs[api.Name]; exists && !overwrite { + return fmt.Errorf("API with name '%s' already exists. Set --overwrite to true to overwrite", api.Name) + } + + // Add/update API in config + cfg.APIs[api.Name] = api + + // Ensure parent directory exists + parentDir := filepath.Dir(file) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return fmt.Errorf("failed to create parent directory for config file: %w", err) + } + + // Open file for writing + f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to open config file: %w", err) + } + defer f.Close() + + // Encode and write config + if err := toml.NewEncoder(f).Encode(cfg); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + +func DefaultConfigFile() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.toml"), nil +} + +// ListAPIs returns a slice of all API configurations in the specified config file. +func ListAPIs(file string) ([]API, error) { + cfg, err := ReadConfigFromFile(file) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + apis := make([]API, 0, len(cfg.APIs)) + for _, api := range cfg.APIs { + apis = append(apis, api) + } + return apis, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..f19b790 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,63 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfigEndToEnd(t *testing.T) { + // Create temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_config.toml") + + // First read should fail since file doesn't exist + _, err := ReadConfigFromFile(testFile) + assert.Error(t, err) + assert.True(t, errors.Is(err, os.ErrNotExist)) + + // Create test API config + testAPI := API{ + Name: "test-api", + OpenAPIPath: "/path/to/openapi.yaml", + ServerURL: "https://api.example.com", + Headers: []string{"Authorization=Bearer token"}, + PathPrefix: "/v1", + } + + // Write API config to file + err = WriteAPIWithName(testFile, testAPI, false) + assert.NoError(t, err) + + // Read config back and verify contents + cfg, err := ReadConfigFromFile(testFile) + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.Len(t, cfg.APIs, 1) + + // Verify API config matches what we wrote + savedAPI, exists := cfg.APIs[testAPI.Name] + assert.True(t, exists) + assert.Equal(t, testAPI, savedAPI) +} + +func TestWriteAPIWithEmptyName(t *testing.T) { + // Create temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_config.toml") + + testAPI := API{ + Name: "", // Empty name should cause an error + OpenAPIPath: "/path/to/openapi.yaml", + ServerURL: "https://api.example.com", + Headers: []string{"Authorization=Bearer token"}, + PathPrefix: "/v1", + } + + err := WriteAPIWithName(testFile, testAPI, false) + assert.Error(t, err) + assert.Equal(t, "api name cannot be empty", err.Error()) +}