Skip to content

Commit a03af28

Browse files
committed
feat: output report to file
1 parent 7d610dc commit a03af28

13 files changed

Lines changed: 460 additions & 17 deletions

cmd/validate.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ var ocpp21Schemas embed.FS
3838

3939
var additionalOcppSchemasFolder = ""
4040

41+
// supportedOutputFormats lists allowed output file formats for the CLI report writer.
42+
var supportedOutputFormats = map[string]bool{".json": true, ".csv": true, ".txt": true}
43+
4144
// registerSchemas registers all schemas from the embedded filesystem for a specific OCPP version.
4245
func registerSchemas(logger *zap.Logger, embeddedDir embed.FS, version ocpp.Version, registry schema_registry.SchemaRegistry) error {
4346
logger.Debug("Registering OCPP schemas", zap.String("version", version.String()))
@@ -175,16 +178,43 @@ var validate = &cobra.Command{
175178
message = args[0]
176179
}
177180

181+
output := viper.GetString("output")
182+
183+
// Validate provided output extension if present
184+
if output != "" {
185+
ext := strings.ToLower(filepath.Ext(output))
186+
if !supportedOutputFormats[ext] {
187+
return errors.Errorf("unsupported output format '%s', supported: .json, .csv, .txt", ext)
188+
}
189+
}
190+
191+
validationOpts := []validation.Option{}
192+
if output != "" {
193+
validationOpts = append(validationOpts, validation.WithOutput(output))
194+
}
195+
178196
switch {
179197
case file == "" && message == "":
180198
return errors.New("no message provided to validate, please provide a message as a command line argument or use the --file flag to read from a file")
181199
case message != "":
182200
// The message is expected to be a JSON string in the format:
183201
// '[2, "uniqueId", "BootNotification", {"chargePointVendor": "TestVendor", "chargePointModel": "TestModel"}]'
184-
return service.ValidateMessage(message, version)
202+
if output == "" {
203+
return service.ValidateMessage(message, version)
204+
}
205+
206+
// Validate and write report
207+
r, err := service.ValidateMessageWithReport(message, version)
208+
if err != nil {
209+
return err
210+
}
211+
212+
return validation.WriteReport(output, r)
213+
185214
case file != "":
215+
// Use the options pattern to write output using registered strategies
186216
// Read the messages from the file
187-
return service.ValidateFile(file, version)
217+
return service.ValidateFile(file, version, validationOpts...)
188218
}
189219

190220
return nil
@@ -196,7 +226,9 @@ func init() {
196226
validate.Flags().StringVarP(&additionalOcppSchemasFolder, "schemas", "a", "", "Path to additional OCPP schemas folder")
197227
validate.Flags().StringP("response-type", "r", "", "Response type to validate against (e.g. 'BootNotificationResponse'). Currently needed if you want to validate a single response message. ")
198228
validate.Flags().StringP("file", "f", "", "Path to a file containing the OCPP message to validate. If this flag is set, the message will be read from the file instead of the command line argument.")
229+
validate.Flags().StringP("output", "o", "", "Path to write validation report. Supports .json, .csv and .txt extensions.")
199230

200231
_ = viper.BindPFlag("response-type", validate.Flags().Lookup("response-type"))
201232
_ = viper.BindPFlag("file", validate.Flags().Lookup("file"))
233+
_ = viper.BindPFlag("output", validate.Flags().Lookup("output"))
202234
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package validation
2+
3+
import (
4+
"encoding/csv"
5+
"os"
6+
"strings"
7+
8+
"github.com/ChargePi/chargeflow/pkg/report"
9+
)
10+
11+
// csvStrategy implements OutputStrategy for CSV output.
12+
type csvStrategy struct{}
13+
14+
func (csvStrategy) Write(path string, r *report.Report) error {
15+
f, err := os.Create(path)
16+
if err != nil {
17+
return err
18+
}
19+
defer f.Close()
20+
21+
w := csv.NewWriter(f)
22+
defer w.Flush()
23+
24+
// Header
25+
if err = w.Write([]string{"message_id", "type", "errors"}); err != nil {
26+
return err
27+
}
28+
29+
// Invalid messages
30+
for msgID, rr := range r.InvalidMessages {
31+
for typ, errs := range rr {
32+
if err = w.Write([]string{msgID, typ, strings.Join(errs, " | ")}); err != nil {
33+
return err
34+
}
35+
}
36+
}
37+
38+
// Non parsable messages
39+
for msgID, errs := range r.NonParsableMessages {
40+
if err = w.Write([]string{msgID, "non_parsable", strings.Join(errs, " | ")}); err != nil {
41+
return err
42+
}
43+
}
44+
45+
return nil
46+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package validation
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/ChargePi/chargeflow/pkg/report"
12+
)
13+
14+
func TestCSVStrategy_Write(t *testing.T) {
15+
dir, err := os.MkdirTemp("", "csv-strat-test")
16+
require.NoError(t, err)
17+
defer os.RemoveAll(dir)
18+
19+
path := filepath.Join(dir, "out.csv")
20+
21+
r := &report.Report{
22+
InvalidMessages: map[string]map[string][]string{
23+
"m1": {
24+
"request": []string{"e1", "e2"},
25+
},
26+
},
27+
NonParsableMessages: map[string][]string{"p1": {"pe1"}},
28+
Statistics: report.Statistics{},
29+
}
30+
31+
s := csvStrategy{}
32+
require.NoError(t, s.Write(path, r))
33+
34+
b, err := os.ReadFile(path)
35+
require.NoError(t, err)
36+
37+
content := string(b)
38+
require.Truef(t, strings.Contains(content, "message_id,type,errors") || strings.Contains(content, "message_id, type, errors"), "csv header missing, got: %s", content)
39+
require.Contains(t, content, "m1", "expected m1 in csv")
40+
require.Contains(t, content, "non_parsable", "expected non_parsable in csv")
41+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package validation
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
7+
"github.com/ChargePi/chargeflow/pkg/report"
8+
)
9+
10+
// jsonStrategy implements OutputStrategy for JSON output.
11+
type jsonStrategy struct{}
12+
13+
func (jsonStrategy) Write(path string, r *report.Report) error {
14+
out := struct {
15+
Report *report.Report `json:"report"`
16+
Statistics report.Statistics `json:"statistics"`
17+
}{
18+
Report: r,
19+
Statistics: r.Statistics,
20+
}
21+
22+
b, err := json.MarshalIndent(out, "", " ")
23+
if err != nil {
24+
return err
25+
}
26+
27+
if err = os.WriteFile(path, b, 0644); err != nil {
28+
return err
29+
}
30+
31+
return nil
32+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package validation
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/ChargePi/chargeflow/pkg/report"
12+
)
13+
14+
func TestJSONStrategy_Write(t *testing.T) {
15+
dir, err := os.MkdirTemp("", "json-strat-test")
16+
require.NoError(t, err)
17+
defer os.RemoveAll(dir)
18+
19+
path := filepath.Join(dir, "out.json")
20+
21+
r := &report.Report{
22+
InvalidMessages: map[string]map[string][]string{
23+
"msg1": {"request": {"req-err"}},
24+
},
25+
NonParsableMessages: map[string][]string{"line1": {"parse-err"}},
26+
Statistics: report.Statistics{ValidRequests: 1, InvalidRequests: 1, ValidResponses: 0, InvalidResponses: 0, UnparsableMessages: 1},
27+
}
28+
29+
s := jsonStrategy{}
30+
require.NoError(t, s.Write(path, r))
31+
32+
b, err := os.ReadFile(path)
33+
require.NoError(t, err)
34+
35+
// Unmarshal into a generic map to ensure fields exist
36+
var out map[string]interface{}
37+
require.NoError(t, json.Unmarshal(b, &out))
38+
39+
require.Contains(t, out, "report")
40+
require.Contains(t, out, "statistics")
41+
}

internal/validation/opts.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package validation
2+
3+
// Option is a functional option for ValidateFile.
4+
type Option func(*options)
5+
6+
type options struct {
7+
output string
8+
}
9+
10+
// WithOutput sets the output path for the validation report.
11+
func WithOutput(path string) Option {
12+
return func(o *options) { o.output = path }
13+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package validation
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
7+
"github.com/pkg/errors"
8+
9+
"github.com/ChargePi/chargeflow/pkg/report"
10+
)
11+
12+
// OutputStrategy defines how to write a validation report.
13+
type OutputStrategy interface {
14+
Write(path string, r *report.Report) error
15+
}
16+
17+
// outputStrategyFactory returns an OutputStrategy based on the file extension.
18+
func outputStrategyFactory(path string) (OutputStrategy, error) {
19+
ext := strings.ToLower(filepath.Ext(path))
20+
switch ext {
21+
case ".json":
22+
return jsonStrategy{}, nil
23+
case ".csv":
24+
return csvStrategy{}, nil
25+
case ".txt":
26+
return txtStrategy{}, nil
27+
default:
28+
return nil, errors.Errorf("unsupported output extension: %s", ext)
29+
}
30+
}
31+
32+
// WriteReport is a convenience exported helper that writes the report using the
33+
// appropriate OutputStrategy based on the provided path extension.
34+
func WriteReport(path string, r *report.Report) error {
35+
strat, err := outputStrategyFactory(path)
36+
if err != nil {
37+
return err
38+
}
39+
return strat.Write(path, r)
40+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package validation
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestOutputStrategyFactory(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
path string
14+
wantErr bool
15+
}{
16+
{"json", "a.json", false},
17+
{"csv", "b.csv", false},
18+
{"txt", "c.txt", false},
19+
{"bad", "d.unknown", true},
20+
}
21+
22+
for _, tt := range tests {
23+
t.Run(tt.name, func(t *testing.T) {
24+
strat, err := outputStrategyFactory(tt.path)
25+
if tt.wantErr {
26+
require.Error(t, err)
27+
return
28+
}
29+
require.NoError(t, err)
30+
31+
// ensure strategy concrete type via extension
32+
ext := filepath.Ext(tt.path)
33+
switch ext {
34+
case ".json":
35+
require.IsType(t, jsonStrategy{}, strat)
36+
case ".csv":
37+
require.IsType(t, csvStrategy{}, strat)
38+
case ".txt":
39+
require.IsType(t, txtStrategy{}, strat)
40+
}
41+
})
42+
}
43+
}

0 commit comments

Comments
 (0)