Skip to content

Commit a0fb216

Browse files
authored
Merge pull request #53 from icann/ft-view_stats_json
Allow "view" command to output JSON.
2 parents 59a1852 + a6c7244 commit a0fb216

4 files changed

Lines changed: 336 additions & 4 deletions

File tree

app/cmd/view.go

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
package cmd
44

55
import (
6+
"bytes"
67
"dnsmag/internal"
8+
"fmt"
9+
"os"
710

811
"github.com/spf13/cobra"
912
)
@@ -16,38 +19,76 @@ func newViewCmd() *cobra.Command {
1619
Args: cobra.ExactArgs(1),
1720
RunE: func(cmd *cobra.Command, args []string) error {
1821
stderr := cmd.ErrOrStderr()
22+
stdout := cmd.OutOrStdout()
1923

2024
inputFile := args[0]
2125

2226
var (
2327
verbose bool
28+
json bool
2429
top int
30+
output string
2531
)
2632

2733
parseFlags(cmd, map[string]any{
2834
"verbose": &verbose,
35+
"json": &json,
2936
"top": &top,
37+
"output": &output,
3038
})
3139

40+
if verbose && json {
41+
return fmt.Errorf("--verbose and --json are mutually exclusive")
42+
}
43+
44+
cmd.SilenceUsage = true
45+
3246
seq := internal.NewDatasetSequence(top, nil)
3347

3448
if err := loadDatasets(cmd, seq, []string{inputFile}, verbose); err != nil {
35-
cmd.SilenceUsage = true
3649
return err
3750
}
3851

3952
// Format and print the domain statistics
40-
if err := internal.OutputDatasetStats(stderr, seq.Result, verbose); err != nil {
41-
cmd.SilenceUsage = true
42-
return err
53+
54+
var buf bytes.Buffer
55+
if json {
56+
if err := internal.OutputDatasetStatsJSON(&buf, seq.Result); err != nil {
57+
return err
58+
}
59+
} else {
60+
if err := internal.OutputDatasetStats(&buf, seq.Result, verbose); err != nil {
61+
return err
62+
}
63+
}
64+
65+
// Write buffer to stderr (default), stdout or file
66+
if output != "" && output != "-" {
67+
// Write to file
68+
// #nosec G306
69+
if err := os.WriteFile(output, buf.Bytes(), 0o644); err != nil {
70+
return fmt.Errorf("failed to write to %s: %w", output, err)
71+
}
72+
} else if output == "-" {
73+
// Write to stdout
74+
if _, err := stdout.Write(buf.Bytes()); err != nil {
75+
return fmt.Errorf("failed to write to stdout: %w", err)
76+
}
77+
} else {
78+
// Default: stderr
79+
if _, err := stderr.Write(buf.Bytes()); err != nil {
80+
return fmt.Errorf("failed to write to stderr: %w", err)
81+
}
4382
}
4483

4584
return nil
4685
},
4786
}
4887

4988
viewCmd.Flags().BoolP("verbose", "v", false, "Verbose output")
89+
viewCmd.Flags().BoolP("json", "j", false, "JSON output")
5090
viewCmd.Flags().IntP("top", "n", internal.DefaultDomainCount, "Number of top domains to display")
91+
viewCmd.Flags().StringP("output", "o", "", "Output file (optional, use '-' for stdout, defaults to stderr)")
5192

5293
return viewCmd
5394
}

app/cmd/view_test.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ package cmd
22

33
import (
44
"bytes"
5+
"dnsmag/internal"
6+
"encoding/json"
7+
"fmt"
58
"os"
69
"regexp"
10+
"strings"
711
"testing"
812
)
913

@@ -62,6 +66,194 @@ func TestViewCmd_Integration(t *testing.T) {
6266
t.Logf("View command output:\n%s", output)
6367
}
6468

69+
func TestViewCmd_JSON(t *testing.T) {
70+
// Create temporary DNSMAG file
71+
tmpDnsmag, err := os.CreateTemp("", "test_view_json_*.dnsmag")
72+
if err != nil {
73+
t.Fatalf("Failed to create temp DNSMAG file: %v", err)
74+
}
75+
defer os.Remove(tmpDnsmag.Name())
76+
tmpDnsmag.Close()
77+
78+
// Collect data into temporary DNSMAG file
79+
executeCollectAndVerify(t, []string{
80+
"../../testdata/test1.pcap.gz",
81+
"--output", tmpDnsmag.Name(),
82+
}, 100, "PCAP")
83+
84+
// View the data with JSON output
85+
viewCmd := newViewCmd()
86+
viewCmd.SetArgs([]string{
87+
tmpDnsmag.Name(),
88+
"--json",
89+
})
90+
91+
var viewBuf bytes.Buffer
92+
viewCmd.SetOut(&viewBuf)
93+
viewCmd.SetErr(&viewBuf)
94+
95+
err = viewCmd.Execute()
96+
if err != nil {
97+
t.Fatalf("View command with --json failed: %v\nOutput: %s", err, viewBuf.String())
98+
}
99+
100+
output := viewBuf.String()
101+
102+
// Parse JSON output
103+
var stats internal.DatasetStatsJSON
104+
if err := json.Unmarshal(viewBuf.Bytes(), &stats); err != nil {
105+
t.Fatalf("Failed to parse JSON output: %v\nOutput: %s", err, output)
106+
}
107+
108+
// Verify ID is non-empty before overwriting
109+
if stats.DatasetStatistics.ID == "" {
110+
t.Error("Expected non-empty ID")
111+
}
112+
113+
// Overwrite random ID field for comparison
114+
stats.DatasetStatistics.ID = ""
115+
116+
expected := internal.DatasetStatsJSON{
117+
DatasetStatistics: internal.DatasetStats{
118+
ID: "",
119+
Generator: fmt.Sprintf("dnsmag %s", internal.Version),
120+
Date: "2000-01-01",
121+
TotalUniqueClients: 70,
122+
TotalQueryVolume: 100,
123+
TotalDomainCount: 4,
124+
},
125+
}
126+
127+
if stats != expected {
128+
t.Errorf("JSON output mismatch.\nGot: %+v\nExpected: %+v", stats, expected)
129+
}
130+
131+
t.Logf("View --json output:\n%s", output)
132+
}
133+
134+
func TestViewCmd_OutputDestination(t *testing.T) {
135+
// Create temporary DNSMAG file
136+
tmpDnsmag, err := os.CreateTemp("", "test_view_output_*.dnsmag")
137+
if err != nil {
138+
t.Fatalf("Failed to create temp DNSMAG file: %v", err)
139+
}
140+
defer os.Remove(tmpDnsmag.Name())
141+
tmpDnsmag.Close()
142+
143+
// Collect data into temporary DNSMAG file
144+
executeCollectAndVerify(t, []string{
145+
"../../testdata/test1.pcap.gz",
146+
"--output", tmpDnsmag.Name(),
147+
}, 100, "PCAP")
148+
149+
tests := []struct {
150+
name string
151+
outputFlag string
152+
jsonFlag bool
153+
expectIn string // "stdout", "stderr", or "file"
154+
searchString string
155+
}{
156+
{
157+
name: "text output to stderr (default)",
158+
outputFlag: "",
159+
jsonFlag: false,
160+
expectIn: "stderr",
161+
searchString: "Dataset statistics",
162+
},
163+
{
164+
name: "text output to file",
165+
outputFlag: "output.txt",
166+
jsonFlag: false,
167+
expectIn: "file",
168+
searchString: "Dataset statistics",
169+
},
170+
{
171+
name: "text output to stdout",
172+
outputFlag: "-",
173+
jsonFlag: false,
174+
expectIn: "stdout",
175+
searchString: "Dataset statistics",
176+
},
177+
{
178+
name: "JSON output to stderr (default)",
179+
outputFlag: "",
180+
jsonFlag: true,
181+
expectIn: "stderr",
182+
searchString: "datasetStatistics",
183+
},
184+
{
185+
name: "JSON output to file",
186+
outputFlag: "output.json",
187+
jsonFlag: true,
188+
expectIn: "file",
189+
searchString: "datasetStatistics",
190+
},
191+
{
192+
name: "JSON output to stdout",
193+
outputFlag: "-",
194+
jsonFlag: true,
195+
expectIn: "stdout",
196+
searchString: "datasetStatistics",
197+
},
198+
}
199+
200+
for _, tt := range tests {
201+
t.Run(tt.name, func(t *testing.T) {
202+
viewCmd := newViewCmd()
203+
args := []string{tmpDnsmag.Name()}
204+
if tt.jsonFlag {
205+
args = append(args, "--json")
206+
}
207+
208+
var outputPath string
209+
if tt.outputFlag != "" {
210+
if tt.expectIn == "file" {
211+
tmpFile, err := os.CreateTemp("", tt.outputFlag)
212+
if err != nil {
213+
t.Fatalf("Failed to create temp file: %v", err)
214+
}
215+
outputPath = tmpFile.Name()
216+
tmpFile.Close()
217+
defer os.Remove(outputPath)
218+
args = append(args, "--output", outputPath)
219+
} else {
220+
args = append(args, "--output", tt.outputFlag)
221+
}
222+
}
223+
viewCmd.SetArgs(args)
224+
225+
var stdout, stderr bytes.Buffer
226+
viewCmd.SetOut(&stdout)
227+
viewCmd.SetErr(&stderr)
228+
229+
err := viewCmd.Execute()
230+
if err != nil {
231+
t.Fatalf("View command failed: %v\nStdout: %s\nStderr: %s", err, stdout.String(), stderr.String())
232+
}
233+
234+
// Check output appears in expected location
235+
switch tt.expectIn {
236+
case "stdout":
237+
if !strings.Contains(stdout.String(), tt.searchString) {
238+
t.Errorf("Expected %q in stdout", tt.searchString)
239+
}
240+
case "stderr":
241+
if !strings.Contains(stderr.String(), tt.searchString) {
242+
t.Errorf("Expected %q in stderr", tt.searchString)
243+
}
244+
case "file":
245+
fileContent, err := os.ReadFile(outputPath)
246+
if err != nil {
247+
t.Fatalf("Failed to read output file: %v", err)
248+
}
249+
if !strings.Contains(string(fileContent), tt.searchString) {
250+
t.Errorf("Expected %q in file", tt.searchString)
251+
}
252+
}
253+
})
254+
}
255+
}
256+
65257
func TestViewCmd_NonExistentFile(t *testing.T) {
66258
viewCmd := newViewCmd()
67259
viewCmd.SetArgs([]string{"non-existent.dnsmag"})

internal/stats.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package internal
44

55
import (
6+
"encoding/json"
67
"fmt"
78
"io"
89
"math"
@@ -190,6 +191,44 @@ func OutputDatasetStats(w io.Writer, dataset MagnitudeDataset, verbose bool) err
190191
return printTable(w, table)
191192
}
192193

194+
// DatasetStats represents the nested dataset statistics
195+
type DatasetStats struct {
196+
ID string `json:"id"`
197+
Generator string `json:"generator"`
198+
Date string `json:"date"`
199+
TotalUniqueClients uint64 `json:"totalUniqueClients"`
200+
TotalQueryVolume uint64 `json:"totalQueryVolume"`
201+
TotalDomainCount uint64 `json:"totalDomainCount"`
202+
}
203+
204+
// DatasetStatsJSON represents the JSON output format for dataset statistics
205+
type DatasetStatsJSON struct {
206+
DatasetStatistics DatasetStats `json:"datasetStatistics"`
207+
}
208+
209+
// OutputDatasetStatsJSON formats and prints dataset statistics as JSON
210+
func OutputDatasetStatsJSON(w io.Writer, dataset MagnitudeDataset) error {
211+
dateStr := ""
212+
if dataset.Date != nil {
213+
dateStr = dataset.Date.Format("2006-01-02")
214+
}
215+
216+
stats := DatasetStatsJSON{
217+
DatasetStatistics: DatasetStats{
218+
ID: dataset.Identifier,
219+
Generator: dataset.Generator,
220+
Date: dateStr,
221+
TotalUniqueClients: dataset.AllClientsCount,
222+
TotalQueryVolume: dataset.AllQueriesCount,
223+
TotalDomainCount: uint64(len(dataset.Domains)),
224+
},
225+
}
226+
227+
encoder := json.NewEncoder(w)
228+
encoder.SetIndent("", " ")
229+
return encoder.Encode(stats)
230+
}
231+
193232
// OutputCollectorStats formats and prints both dataset and timing statistics for collection operations
194233
func OutputCollectorStats(w io.Writer, collector *Collector, verbose bool) error {
195234
if len(collector.filesLoaded) == 1 {

0 commit comments

Comments
 (0)