From 408d62c592896e0315e0bb366f9da1f87659692d Mon Sep 17 00:00:00 2001 From: swarnabhasinha Date: Mon, 13 Oct 2025 00:22:23 +0530 Subject: [PATCH] feat: add JSON and YAML output formats for all commands --- README.md | 5 ++ cmd/auth.go | 113 +++++++++++++++++++++++++------- cmd/health.go | 39 +++++++---- cmd/list.go | 73 ++++++++++++++------- cmd/root.go | 17 +++-- cmd/usages.go | 77 +++++++++++++++++++--- cmd/version.go | 22 ++++++- internal/formatter/formatter.go | 92 ++++++++++++++++++++++++++ 8 files changed, 361 insertions(+), 77 deletions(-) create mode 100644 internal/formatter/formatter.go diff --git a/README.md b/README.md index d36039d..df13afd 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ It supports authenticated publishing, subscribing, rate-limited usage tracking, - [x] Health monitoring and system metrics - [x] Debug mode with detailed timing and proxy information - [x] Development mode with `--disable-auth` flag for testing +- [x] Multiple output formats (table, JSON, YAML) for automation and scripting --- @@ -73,6 +74,10 @@ Download from [releases](https://github.com/getoptimum/mump2p-cli/releases/lates # List your active topics ./mump2p list-topics +# Output formats - JSON/YAML for automation and scripting +./mump2p list-topics --output=json +./mump2p whoami --output=yaml + # Debug mode - detailed timing and proxy information ./mump2p --debug publish --topic=test-topic --message='Hello World' ./mump2p --debug subscribe --topic=test-topic diff --git a/cmd/auth.go b/cmd/auth.go index 8f79631..bf1b26c 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -5,9 +5,29 @@ import ( "time" "github.com/getoptimum/mump2p-cli/internal/auth" + "github.com/getoptimum/mump2p-cli/internal/formatter" "github.com/spf13/cobra" ) +// WhoamiResponse represents structured authentication status +type WhoamiResponse struct { + ClientID string `json:"client_id" yaml:"client_id"` + Expires string `json:"expires,omitempty" yaml:"expires,omitempty"` + ValidFor string `json:"valid_for,omitempty" yaml:"valid_for,omitempty"` + IsActive bool `json:"is_active" yaml:"is_active"` + IsExpired bool `json:"is_expired,omitempty" yaml:"is_expired,omitempty"` + AuthMode string `json:"auth_mode" yaml:"auth_mode"` + RateLimits *RateLimitInfo `json:"rate_limits,omitempty" yaml:"rate_limits,omitempty"` +} + +// RateLimitInfo represents rate limit information +type RateLimitInfo struct { + PublishPerHour int `json:"publish_per_hour" yaml:"publish_per_hour"` + PublishPerSec int `json:"publish_per_sec" yaml:"publish_per_sec"` + MaxMessageSizeMB float64 `json:"max_message_size_mb" yaml:"max_message_size_mb"` + DailyQuotaMB float64 `json:"daily_quota_mb" yaml:"daily_quota_mb"` +} + // loginCmd represents the login command var loginCmd = &cobra.Command{ Use: "login", @@ -57,18 +77,35 @@ var whoamiCmd = &cobra.Command{ Short: "Show current authentication status", Long: `Display information about the current authentication token.`, RunE: func(cmd *cobra.Command, args []string) error { + f := formatter.New(GetOutputFormat()) + if IsAuthDisabled() { // Display authentication status when auth is disabled clientIDToUse := GetClientID() if clientIDToUse == "" { clientIDToUse = "(not set - use --client-id flag)" } - fmt.Println("Authentication Status:") - fmt.Println("----------------------") - fmt.Printf("Client ID: %s\n", clientIDToUse) - fmt.Println("Auth Mode: Disabled (using --disable-auth)") - fmt.Println("Rate Limits: N/A (no limits enforced)") - fmt.Println("Token: N/A (auth disabled)") + + response := WhoamiResponse{ + ClientID: clientIDToUse, + AuthMode: "disabled", + IsActive: true, + } + + if f.IsTable() { + fmt.Println("Authentication Status:") + fmt.Println("----------------------") + fmt.Printf("Client ID: %s\n", clientIDToUse) + fmt.Println("Auth Mode: Disabled (using --disable-auth)") + fmt.Println("Rate Limits: N/A (no limits enforced)") + fmt.Println("Token: N/A (auth disabled)") + } else { + output, err := f.Format(response) + if err != nil { + return fmt.Errorf("failed to format output: %v", err) + } + fmt.Println(output) + } return nil } @@ -86,31 +123,57 @@ var whoamiCmd = &cobra.Command{ return fmt.Errorf("error parsing token: %v", err) } - // display token information - fmt.Println("Authentication Status:") - fmt.Println("----------------------") - - if claims.Subject != "" { - fmt.Printf("Client ID: %s\n", claims.Subject) + // Prepare structured response + isExpired := time.Now().After(claims.ExpiresAt) + response := WhoamiResponse{ + ClientID: claims.Subject, + Expires: claims.ExpiresAt.Format(time.RFC822), + ValidFor: time.Until(claims.ExpiresAt).Round(time.Minute).String(), + IsActive: claims.IsActive, + IsExpired: isExpired, + AuthMode: "enabled", + RateLimits: &RateLimitInfo{ + PublishPerHour: claims.MaxPublishPerHour, + PublishPerSec: claims.MaxPublishPerSec, + MaxMessageSizeMB: float64(claims.MaxMessageSize) / (1 << 20), + DailyQuotaMB: float64(claims.DailyQuota) / (1 << 20), + }, } - fmt.Printf("Expires: %s\n", claims.ExpiresAt.Format(time.RFC822)) + if f.IsTable() { + // display token information (table format) + fmt.Println("Authentication Status:") + fmt.Println("----------------------") + + if claims.Subject != "" { + fmt.Printf("Client ID: %s\n", claims.Subject) + } + + fmt.Printf("Expires: %s\n", claims.ExpiresAt.Format(time.RFC822)) + + if isExpired { + fmt.Println("Token has expired. Please login again.") + } else { + fmt.Printf("Valid for: %s\n", time.Until(claims.ExpiresAt).Round(time.Minute)) + } - if time.Now().After(claims.ExpiresAt) { - fmt.Println("Token has expired. Please login again.") + fmt.Printf("Is Active: %t\n", claims.IsActive) + // display rate limit information + fmt.Println("\nRate Limits:") + fmt.Println("------------") + fmt.Printf("Publish Rate: %d per hour\n", claims.MaxPublishPerHour) + fmt.Printf("Publish Rate: %d per second\n", claims.MaxPublishPerSec) + fmt.Printf("Max Message Size: %.2f MB\n", float64(claims.MaxMessageSize)/(1<<20)) + fmt.Printf("Daily Quota: %.2f MB\n", float64(claims.DailyQuota)/(1<<20)) } else { - fmt.Printf("Valid for: %s\n", time.Until(claims.ExpiresAt).Round(time.Minute)) + // JSON or YAML format + output, err := f.Format(response) + if err != nil { + return fmt.Errorf("failed to format output: %v", err) + } + fmt.Println(output) } - fmt.Printf("Is Active: %t\n", claims.IsActive) - // display rate limit information - fmt.Println("\nRate Limits:") - fmt.Println("------------") - fmt.Printf("Publish Rate: %d per hour\n", claims.MaxPublishPerHour) - fmt.Printf("Publish Rate: %d per second\n", claims.MaxPublishPerSec) - fmt.Printf("Max Message Size: %.2f MB\n", float64(claims.MaxMessageSize)/(1<<20)) - fmt.Printf("Daily Quota: %.2f MB\n", float64(claims.DailyQuota)/(1<<20)) - return nil }, } diff --git a/cmd/health.go b/cmd/health.go index e30e7c4..45e8c3b 100644 --- a/cmd/health.go +++ b/cmd/health.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/getoptimum/mump2p-cli/internal/config" + "github.com/getoptimum/mump2p-cli/internal/formatter" "github.com/spf13/cobra" ) @@ -16,10 +17,12 @@ var ( // HealthResponse represents the response from the health endpoint type HealthResponse struct { - Status string `json:"status"` - MemoryUsed string `json:"memory_used"` - CPUUsed string `json:"cpu_used"` - DiskUsed string `json:"disk_used"` + Status string `json:"status" yaml:"status"` + MemoryUsed string `json:"memory_used" yaml:"memory_used"` + CPUUsed string `json:"cpu_used" yaml:"cpu_used"` + DiskUsed string `json:"disk_used" yaml:"disk_used"` + Country string `json:"country,omitempty" yaml:"country,omitempty"` + CountryISO string `json:"country_iso,omitempty" yaml:"country_iso,omitempty"` } var healthCmd = &cobra.Command{ @@ -62,13 +65,27 @@ var healthCmd = &cobra.Command{ return nil } - // Display formatted health information - fmt.Println("Proxy Health Status:") - fmt.Println("-------------------") - fmt.Printf("Status: %s\n", healthResp.Status) - fmt.Printf("Memory Used: %s%%\n", healthResp.MemoryUsed) - fmt.Printf("CPU Used: %s%%\n", healthResp.CPUUsed) - fmt.Printf("Disk Used: %s%%\n", healthResp.DiskUsed) + f := formatter.New(GetOutputFormat()) + + if f.IsTable() { + // Display formatted health information (default table format) + fmt.Println("Proxy Health Status:") + fmt.Println("-------------------") + fmt.Printf("Status: %s\n", healthResp.Status) + fmt.Printf("Memory Used: %s%%\n", healthResp.MemoryUsed) + fmt.Printf("CPU Used: %s%%\n", healthResp.CPUUsed) + fmt.Printf("Disk Used: %s%%\n", healthResp.DiskUsed) + if healthResp.Country != "" { + fmt.Printf("Country: %s (%s)\n", healthResp.Country, healthResp.CountryISO) + } + } else { + // JSON or YAML format + output, err := f.Format(healthResp) + if err != nil { + return fmt.Errorf("failed to format output: %v", err) + } + fmt.Println(output) + } return nil }, diff --git a/cmd/list.go b/cmd/list.go index bc1e6dc..2089ff7 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -8,6 +8,7 @@ import ( "github.com/getoptimum/mump2p-cli/internal/auth" "github.com/getoptimum/mump2p-cli/internal/config" + "github.com/getoptimum/mump2p-cli/internal/formatter" "github.com/spf13/cobra" ) @@ -17,9 +18,9 @@ var ( // ListResponse represents the response from the /api/v1/topics endpoint type ListResponse struct { - ClientID string `json:"client_id"` - Topics []string `json:"topics"` - Count int `json:"count"` + ClientID string `json:"client_id" yaml:"client_id"` + Topics []string `json:"topics" yaml:"topics"` + Count int `json:"count" yaml:"count"` } var listTopicsCmd = &cobra.Command{ @@ -76,21 +77,32 @@ This command shows your active topics and their count.`, return fmt.Errorf("failed to parse response JSON: %v", err) } - // Display results in a formatted table - fmt.Printf("\nšŸ“‹ Subscribed Topics for Client: %s (Auth Disabled)\n", listResponse.ClientID) - fmt.Printf("═══════════════════════════════════════════════════════════════\n") + f := formatter.New(GetOutputFormat()) + + if f.IsTable() { + // Display results in a formatted table + fmt.Printf("\nšŸ“‹ Subscribed Topics for Client: %s (Auth Disabled)\n", listResponse.ClientID) + fmt.Printf("═══════════════════════════════════════════════════════════════\n") + + if listResponse.Count == 0 { + fmt.Printf(" No active topics found.\n") + fmt.Printf(" Use './mump2p subscribe --topic=' to subscribe to a topic.\n") + } else { + fmt.Printf(" Total Topics: %d\n\n", listResponse.Count) + for i, topic := range listResponse.Topics { + fmt.Printf(" %d. %s\n", i+1, topic) + } + } - if listResponse.Count == 0 { - fmt.Printf(" No active topics found.\n") - fmt.Printf(" Use './mump2p subscribe --topic=' to subscribe to a topic.\n") + fmt.Printf("═══════════════════════════════════════════════════════════════\n") } else { - fmt.Printf(" Total Topics: %d\n\n", listResponse.Count) - for i, topic := range listResponse.Topics { - fmt.Printf(" %d. %s\n", i+1, topic) + // JSON or YAML format + output, err := f.Format(listResponse) + if err != nil { + return fmt.Errorf("failed to format output: %v", err) } + fmt.Println(output) } - - fmt.Printf("═══════════════════════════════════════════════════════════════\n") return nil } @@ -157,21 +169,32 @@ This command shows your active topics and their count.`, return fmt.Errorf("failed to parse response JSON: %v", err) } - // Display results in a formatted table - fmt.Printf("\nšŸ“‹ Subscribed Topics for Client: %s\n", listResponse.ClientID) - fmt.Printf("═══════════════════════════════════════════════════════════════\n") + f := formatter.New(GetOutputFormat()) + + if f.IsTable() { + // Display results in a formatted table + fmt.Printf("\nšŸ“‹ Subscribed Topics for Client: %s\n", listResponse.ClientID) + fmt.Printf("═══════════════════════════════════════════════════════════════\n") - if listResponse.Count == 0 { - fmt.Printf(" No active topics found.\n") - fmt.Printf(" Use './mump2p subscribe --topic=' to subscribe to a topic.\n") + if listResponse.Count == 0 { + fmt.Printf(" No active topics found.\n") + fmt.Printf(" Use './mump2p subscribe --topic=' to subscribe to a topic.\n") + } else { + fmt.Printf(" Total Topics: %d\n\n", listResponse.Count) + for i, topic := range listResponse.Topics { + fmt.Printf(" %d. %s\n", i+1, topic) + } + } + + fmt.Printf("═══════════════════════════════════════════════════════════════\n") } else { - fmt.Printf(" Total Topics: %d\n\n", listResponse.Count) - for i, topic := range listResponse.Topics { - fmt.Printf(" %d. %s\n", i+1, topic) + // JSON or YAML format + output, err := f.Format(listResponse) + if err != nil { + return fmt.Errorf("failed to format output: %v", err) } + fmt.Println(output) } - - fmt.Printf("═══════════════════════════════════════════════════════════════\n") return nil }, } diff --git a/cmd/root.go b/cmd/root.go index c767937..8832bf2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,10 +8,11 @@ import ( ) var ( - authPath string // Global flag for custom authentication file path - debug bool // Global flag for debug mode - disableAuth bool // Global flag to disable authentication checks - clientID string // Global flag for client ID (used when auth is disabled) + authPath string // Global flag for custom authentication file path + debug bool // Global flag for debug mode + disableAuth bool // Global flag to disable authentication checks + clientID string // Global flag for client ID (used when auth is disabled) + outputFormat string // Global flag for output format (table, json, yaml) ) var rootCmd = &cobra.Command{ @@ -40,6 +41,9 @@ func init() { // Add global client ID flag rootCmd.PersistentFlags().StringVar(&clientID, "client-id", "", "Client ID to use (required when --disable-auth is enabled)") + // Add global output format flag + rootCmd.PersistentFlags().StringVar(&outputFormat, "output", "table", "Output format (table, json, yaml)") + // disable completion option rootCmd.CompletionOptions.DisableDefaultCmd = true } @@ -72,3 +76,8 @@ func IsAuthDisabled() bool { func GetClientID() string { return clientID } + +// GetOutputFormat returns the output format +func GetOutputFormat() string { + return outputFormat +} diff --git a/cmd/usages.go b/cmd/usages.go index 048c203..2388695 100644 --- a/cmd/usages.go +++ b/cmd/usages.go @@ -5,20 +5,49 @@ import ( "time" "github.com/getoptimum/mump2p-cli/internal/auth" + "github.com/getoptimum/mump2p-cli/internal/formatter" "github.com/getoptimum/mump2p-cli/internal/ratelimit" "github.com/spf13/cobra" ) +// UsageResponse represents structured usage statistics +type UsageResponse struct { + PublishCount int `json:"publish_count" yaml:"publish_count"` + PublishLimitPerHour int `json:"publish_limit_per_hour" yaml:"publish_limit_per_hour"` + SecondPublishCount int `json:"second_publish_count" yaml:"second_publish_count"` + PublishLimitPerSec int `json:"publish_limit_per_sec" yaml:"publish_limit_per_sec"` + BytesPublishedMB float64 `json:"bytes_published_mb" yaml:"bytes_published_mb"` + DailyQuotaMB float64 `json:"daily_quota_mb" yaml:"daily_quota_mb"` + NextReset string `json:"next_reset" yaml:"next_reset"` + TimeUntilReset string `json:"time_until_reset" yaml:"time_until_reset"` + LastPublishTime string `json:"last_publish_time,omitempty" yaml:"last_publish_time,omitempty"` + LastSubscribeTime string `json:"last_subscribe_time,omitempty" yaml:"last_subscribe_time,omitempty"` +} + // usageCmd represents the usage command var usageCmd = &cobra.Command{ Use: "usage", Short: "Display usage statistics and rate limits", RunE: func(cmd *cobra.Command, args []string) error { + f := formatter.New(GetOutputFormat()) + if IsAuthDisabled() { // When auth is disabled, usage tracking is not available - fmt.Println("Usage Statistics:") - fmt.Println(" Status: Usage tracking disabled (using --disable-auth)") - fmt.Println(" No rate limits or quotas are enforced in this mode") + if f.IsTable() { + fmt.Println("Usage Statistics:") + fmt.Println(" Status: Usage tracking disabled (using --disable-auth)") + fmt.Println(" No rate limits or quotas are enforced in this mode") + } else { + response := map[string]string{ + "status": "disabled", + "message": "Usage tracking disabled (using --disable-auth)", + } + output, err := f.Format(response) + if err != nil { + return fmt.Errorf("failed to format output: %v", err) + } + fmt.Println(output) + } return nil } @@ -46,17 +75,45 @@ var usageCmd = &cobra.Command{ // get usage statistics stats := limiter.GetUsageStats() - // display usage statistics - fmt.Printf(" Publish (hour): %d / %d\n", stats.PublishCount, stats.PublishLimitPerHour) - fmt.Printf(" Publish (second): %d / %d\n", stats.SecondPublishCount, stats.PublishLimitPerSec) - fmt.Printf(" Data Used: %.4f MB / %.4f MB\n", float64(stats.BytesPublished)/(1<<20), float64(stats.DailyQuota)/(1<<20)) - fmt.Printf(" Next Reset: %s (%s from now)\n", stats.NextReset.Format(time.RFC822), stats.TimeUntilReset) + // Prepare structured response + response := UsageResponse{ + PublishCount: stats.PublishCount, + PublishLimitPerHour: stats.PublishLimitPerHour, + SecondPublishCount: stats.SecondPublishCount, + PublishLimitPerSec: stats.PublishLimitPerSec, + BytesPublishedMB: float64(stats.BytesPublished) / (1 << 20), + DailyQuotaMB: float64(stats.DailyQuota) / (1 << 20), + NextReset: stats.NextReset.Format(time.RFC822), + TimeUntilReset: stats.TimeUntilReset.String(), + } if !stats.LastPublishTime.IsZero() { - fmt.Printf(" Last Publish: %s\n", stats.LastPublishTime.Format(time.RFC822)) + response.LastPublishTime = stats.LastPublishTime.Format(time.RFC822) } if !stats.LastSubscribeTime.IsZero() { - fmt.Printf(" Last Subscribe: %s\n", stats.LastSubscribeTime.Format(time.RFC822)) + response.LastSubscribeTime = stats.LastSubscribeTime.Format(time.RFC822) + } + + if f.IsTable() { + // display usage statistics (table format) + fmt.Printf(" Publish (hour): %d / %d\n", stats.PublishCount, stats.PublishLimitPerHour) + fmt.Printf(" Publish (second): %d / %d\n", stats.SecondPublishCount, stats.PublishLimitPerSec) + fmt.Printf(" Data Used: %.4f MB / %.4f MB\n", float64(stats.BytesPublished)/(1<<20), float64(stats.DailyQuota)/(1<<20)) + fmt.Printf(" Next Reset: %s (%s from now)\n", stats.NextReset.Format(time.RFC822), stats.TimeUntilReset) + + if !stats.LastPublishTime.IsZero() { + fmt.Printf(" Last Publish: %s\n", stats.LastPublishTime.Format(time.RFC822)) + } + if !stats.LastSubscribeTime.IsZero() { + fmt.Printf(" Last Subscribe: %s\n", stats.LastSubscribeTime.Format(time.RFC822)) + } + } else { + // JSON or YAML format + output, err := f.Format(response) + if err != nil { + return fmt.Errorf("failed to format output: %v", err) + } + fmt.Println(output) } return nil diff --git a/cmd/version.go b/cmd/version.go index d50161a..23a4bb1 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/getoptimum/mump2p-cli/internal/config" + "github.com/getoptimum/mump2p-cli/internal/formatter" "github.com/spf13/cobra" ) @@ -13,8 +14,25 @@ var versionCmd = &cobra.Command{ Short: "Show CLI version", Long: `Display the current version and Git commit used to build this binary.`, Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Version:", config.Version) - fmt.Println("Commit: ", config.CommitHash) + f := formatter.New(GetOutputFormat()) + + if f.IsTable() { + // Table format (default) + fmt.Println("Version:", config.Version) + fmt.Println("Commit: ", config.CommitHash) + } else { + // JSON or YAML format + data := map[string]string{ + "version": config.Version, + "commit_hash": config.CommitHash, + } + output, err := f.Format(data) + if err != nil { + fmt.Printf("Error formatting output: %v\n", err) + return + } + fmt.Println(output) + } }, } diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go new file mode 100644 index 0000000..8f6d829 --- /dev/null +++ b/internal/formatter/formatter.go @@ -0,0 +1,92 @@ +package formatter + +import ( + "encoding/json" + "fmt" + "strings" + + "gopkg.in/yaml.v2" +) + +// OutputFormat represents the output format type +type OutputFormat string + +const ( + FormatTable OutputFormat = "table" + FormatJSON OutputFormat = "json" + FormatYAML OutputFormat = "yaml" +) + +// Formatter handles output formatting for different formats +type Formatter struct { + format OutputFormat +} + +// New creates a new formatter with the specified format +func New(format string) *Formatter { + f := &Formatter{ + format: FormatTable, // default + } + + switch strings.ToLower(format) { + case "json": + f.format = FormatJSON + case "yaml", "yml": + f.format = FormatYAML + case "table", "": + f.format = FormatTable + } + + return f +} + +// Format formats the data according to the configured format +func (f *Formatter) Format(data interface{}) (string, error) { + switch f.format { + case FormatJSON: + return f.formatJSON(data) + case FormatYAML: + return f.formatYAML(data) + case FormatTable: + // For table format, data should already be formatted as string + if str, ok := data.(string); ok { + return str, nil + } + return fmt.Sprintf("%v", data), nil + default: + return "", fmt.Errorf("unsupported format: %s", f.format) + } +} + +// formatJSON formats data as JSON +func (f *Formatter) formatJSON(data interface{}) (string, error) { + jsonBytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %v", err) + } + return string(jsonBytes), nil +} + +// formatYAML formats data as YAML +func (f *Formatter) formatYAML(data interface{}) (string, error) { + yamlBytes, err := yaml.Marshal(data) + if err != nil { + return "", fmt.Errorf("failed to marshal YAML: %v", err) + } + return string(yamlBytes), nil +} + +// IsTable returns true if the format is table +func (f *Formatter) IsTable() bool { + return f.format == FormatTable +} + +// IsJSON returns true if the format is JSON +func (f *Formatter) IsJSON() bool { + return f.format == FormatJSON +} + +// IsYAML returns true if the format is YAML +func (f *Formatter) IsYAML() bool { + return f.format == FormatYAML +}