Skip to content

Commit 8f09b80

Browse files
xBlaz3kxCopilot
andauthored
feat: message validation from file (#8)
* feat: new parser implementation * feat: statistics Co-authored-by: Copilot <[email protected]> * fix: missing function test * fix: fmt * docs: update * fix: imports * chore: additional test cases for 0 * feat: added result aggregator * fix: updated result to contain the ocpp message (needed for validation) * feat: added file validation (wip) * fix: issues and improved logging experience. * docs: update * feat: unit tests for service --------- Co-authored-by: Copilot <[email protected]>
1 parent 506a936 commit 8f09b80

File tree

19 files changed

+2310
-72
lines changed

19 files changed

+2310
-72
lines changed

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ with various Charge Point Management Systems or Charge Point implementations.
88
- [x] Parse Raw OCPP JSON messages
99
- [x] Support for OCPP 1.6 and 2.0.1
1010
- [x] Request and Response payload validation
11+
- [x] Validate messages from a file
1112

1213
### Roadmap
1314

1415
- [ ] Generate human-readable reports
15-
- [ ] Validate messages from a file
1616
- [ ] Support for OCPP 2.1
1717
- [ ] Support for signed messages
1818
- [ ] Compatibility checks
@@ -45,11 +45,14 @@ Usage:
4545
chargeflow validate [flags]
4646

4747
Examples:
48-
chargeflow --version 1.6 validate '[1, "123456", "BootNotification", {"chargePointVendor": "TestVendor", "chargePointModel": "TestModel"}]'
48+
chargeflow --version 1.6 validate '[2, "123456", "BootNotification", {"chargePointVendor": "TestVendor", "chargePointModel": "TestModel"}]'
49+
chargeflow validate -f ./message.txt
4950

5051
Flags:
51-
-h, --help help for validate
52-
-a, --schemas string Path to additional OCPP schemas folder
52+
-f, --file string 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.
53+
-h, --help help for validate
54+
-r, --response-type string Response type to validate against (e.g. 'BootNotificationResponse'). Currently needed if you want to validate a single response message.
55+
-a, --schemas string Path to additional OCPP schemas folder
5356

5457
Global Flags:
5558
-d, --debug Enable debug mode
@@ -59,8 +62,15 @@ Global Flags:
5962
ChargeFlow will automatically determine whether it's a request or response message. All you need to provide is a OCPP
6063
version!
6164
65+
> [!NOTE]
66+
> If you want to validate a response message, you need to specify the response type using the `--response-type`
67+
> flag.
68+
6269
Additionally, you can specify a custom path to vendor-specific OCPP schemas using the `--schemas` flag.
6370
71+
> [!TIP]
72+
> You can now also validate multiple messages (both request and responses) from a file using the `-f` flag.
73+
6474
## License
6575
6676
This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details.

cmd/validate.go

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,20 @@ import (
66
"path/filepath"
77
"strings"
88

9+
"github.com/ChargePi/chargeflow/internal/validation"
10+
911
"github.com/spf13/viper"
1012

1113
"github.com/pkg/errors"
1214
"github.com/spf13/cobra"
1315
"go.uber.org/zap"
1416

1517
"github.com/ChargePi/chargeflow/pkg/ocpp"
16-
"github.com/ChargePi/chargeflow/pkg/parser"
1718
"github.com/ChargePi/chargeflow/pkg/schema_registry"
18-
"github.com/ChargePi/chargeflow/pkg/validator"
1919
)
2020

2121
var registry schema_registry.SchemaRegistry
2222

23-
var messageParser *parser.Parser
24-
2523
// OCPP 1.6 schemas
2624
//
2725
//go:embed schemas/ocpp_16/*
@@ -116,15 +114,14 @@ var validate = &cobra.Command{
116114
Use: "validate",
117115
Short: "Validate the OCPP message(s) against the registered OCPP schemas",
118116
Long: `Validate the OCPP message(s) against the registered OCPP schema(s).`,
119-
Example: "chargeflow --version 1.6 validate '[1, \"123456\", \"BootNotification\", {\"chargePointVendor\": \"TestVendor\", \"chargePointModel\": \"TestModel\"}]'",
120-
Args: cobra.MinimumNArgs(1),
117+
Example: "chargeflow --version 1.6 validate '[2, \"123456\", \"BootNotification\", {\"chargePointVendor\": \"TestVendor\", \"chargePointModel\": \"TestModel\"}]'",
118+
Args: cobra.RangeArgs(0, 1),
121119
SilenceUsage: true,
122120
PreRunE: func(cmd *cobra.Command, args []string) error {
123121
ocppVersion := viper.GetString("ocpp.version")
124122
logger := zap.L()
125123

126124
registry = schema_registry.NewInMemorySchemaRegistry(logger)
127-
messageParser = parser.NewParser(logger)
128125

129126
// Populate the schema registry with OCPP schemas
130127
var err error
@@ -157,41 +154,29 @@ var validate = &cobra.Command{
157154
},
158155
RunE: func(cmd *cobra.Command, args []string) error {
159156
ocppVersion := viper.GetString("ocpp.version")
160-
logger := zap.L()
161-
validator := validator.NewValidator(logger, registry)
162-
163-
// The argument (message) is expected to be a JSON string in the format:
164-
// '[2, "uniqueId", "BootNotification", {"chargePointVendor": "TestVendor", "chargePointModel": "TestModel"}]'
165-
message := args[0]
166-
167-
parseMessage, parseResult, err := messageParser.ParseMessage(message)
168-
if err != nil {
169-
return err
170-
}
157+
file := viper.GetString("file")
158+
version := ocpp.Version(ocppVersion)
171159

172-
if !parseResult.IsValid() {
173-
logger.Info("❌ Failure: The message could not be parsed or had syntax errors:")
174-
for _, err := range parseResult.Errors() {
175-
logger.Info("- " + err)
176-
}
177-
178-
return nil
179-
}
160+
logger := zap.L()
161+
logger = logger.WithOptions(zap.WithCaller(false), zap.AddStacktrace(zap.FatalLevel))
180162

181-
logger.Info("✅ Message successfully parsed. Proceeding with validation.")
163+
service := validation.NewService(logger, registry)
182164

183-
result, err := validator.ValidateMessage(ocpp.Version(ocppVersion), parseMessage)
184-
if err != nil {
185-
return err
165+
var message string
166+
if len(args) > 0 {
167+
message = args[0]
186168
}
187169

188-
if result.IsValid() {
189-
logger.Info("✅ Success: The message is valid according to the OCPP schema.")
190-
} else {
191-
logger.Info("❌ Failure: The message is NOT valid according to the OCPP schema:")
192-
for _, err := range result.Errors() {
193-
logger.Info("- " + err)
194-
}
170+
switch {
171+
case file == "" && message == "":
172+
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")
173+
case message != "":
174+
// The message is expected to be a JSON string in the format:
175+
// '[2, "uniqueId", "BootNotification", {"chargePointVendor": "TestVendor", "chargePointModel": "TestModel"}]'
176+
return service.ValidateMessage(message, version)
177+
case file != "":
178+
// Read the messages from the file
179+
return service.ValidateFile(file, version)
195180
}
196181

197182
return nil
@@ -202,6 +187,8 @@ func init() {
202187
// Add flags for additional OCPP schemas folder
203188
validate.Flags().StringVarP(&additionalOcppSchemasFolder, "schemas", "a", "", "Path to additional OCPP schemas folder")
204189
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. ")
190+
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.")
205191

206192
_ = viper.BindPFlag("response-type", validate.Flags().Lookup("response-type"))
193+
_ = viper.BindPFlag("file", validate.Flags().Lookup("file"))
207194
}

internal/validation/service.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package validation
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"strings"
8+
9+
"github.com/pkg/errors"
10+
"go.uber.org/zap"
11+
12+
"github.com/ChargePi/chargeflow/pkg/ocpp"
13+
"github.com/ChargePi/chargeflow/pkg/parser"
14+
"github.com/ChargePi/chargeflow/pkg/report"
15+
"github.com/ChargePi/chargeflow/pkg/schema_registry"
16+
"github.com/ChargePi/chargeflow/pkg/validator"
17+
)
18+
19+
type Service struct {
20+
logger *zap.Logger
21+
registry schema_registry.SchemaRegistry
22+
parser *parser.ParserV2
23+
validator *validator.Validator
24+
aggregator *report.Aggregator
25+
}
26+
27+
func NewService(
28+
logger *zap.Logger,
29+
registry schema_registry.SchemaRegistry,
30+
) *Service {
31+
return &Service{
32+
logger: logger,
33+
registry: registry,
34+
parser: parser.NewParserV2(logger),
35+
validator: validator.NewValidator(logger, registry),
36+
aggregator: report.NewAggregator(logger),
37+
}
38+
}
39+
40+
// ValidateMessage validates a single OCPP message against the schema.
41+
func (s *Service) ValidateMessage(message string, ocppVersion ocpp.Version) error {
42+
logger := s.logger.With(zap.String("message", message), zap.String("ocppVersion", ocppVersion.String()))
43+
logger.Info("Validating message")
44+
45+
validationReport, err := s.parseAndValidate(ocppVersion, []string{message})
46+
if err != nil {
47+
return errors.Wrap(err, "failed to parse message")
48+
}
49+
50+
s.outputValidationErrorToLogs(validationReport)
51+
52+
return nil
53+
}
54+
55+
// ValidateFile validates a file containing multiple OCPP messages against the schema.
56+
func (s *Service) ValidateFile(file string, ocppVersion ocpp.Version) error {
57+
logger := s.logger.With(zap.String("file", file), zap.String("ocppVersion", ocppVersion.String()))
58+
logger.Info("Validating file")
59+
60+
messages, err := s.getMessagesFromFile(file)
61+
if err != nil {
62+
return errors.Wrap(err, "unable to read messages from file")
63+
}
64+
65+
logger.Info("✅ Successfully parsed file", zap.Int("messages", len(messages)))
66+
67+
validationReport, err := s.parseAndValidate(ocppVersion, messages)
68+
if err != nil {
69+
return errors.Wrap(err, "unable to parse messages")
70+
}
71+
72+
s.outputValidationErrorToLogs(validationReport)
73+
74+
return nil
75+
}
76+
77+
// outputValidationErrorToLogs outputs the validation errors to the logs.
78+
func (s *Service) outputValidationErrorToLogs(validationReport *report.Report) {
79+
if len(validationReport.InvalidMessages) == 0 && len(validationReport.NonParsableMessages) == 0 {
80+
s.logger.Info("✅ All messages are valid!")
81+
return
82+
}
83+
84+
// Log the non-parsable messages first
85+
for line, errs := range validationReport.NonParsableMessages {
86+
logger := s.logger.With(zap.String("line", line))
87+
logger.Error(fmt.Sprintf("Message could not be parsed at %s:", line))
88+
if len(errs) == 0 {
89+
continue
90+
}
91+
92+
for _, parseErr := range errs {
93+
logger.Error(fmt.Sprintf("👉 %s", parseErr))
94+
}
95+
}
96+
97+
// Log any parsing or validation errors for messages
98+
for messageId, requestResponse := range validationReport.InvalidMessages {
99+
for k, validationErrors := range requestResponse {
100+
logger := s.logger.With(zap.String("messageId", messageId))
101+
switch k {
102+
case "request":
103+
logger.Error(fmt.Sprintf("Request for message %s has the following validation errors:", messageId))
104+
case "response":
105+
logger.Error(fmt.Sprintf("Response for message %s has the following validation errors:", messageId))
106+
}
107+
108+
if len(validationErrors) == 0 {
109+
continue
110+
}
111+
112+
for _, parseErr := range validationErrors {
113+
logger.Error(fmt.Sprintf("👉 %s", parseErr))
114+
}
115+
}
116+
}
117+
}
118+
119+
// parseAndValidate parses and validates a list of OCPP messages.
120+
func (s *Service) parseAndValidate(ocppVersion ocpp.Version, messages []string) (*report.Report, error) {
121+
logger := s.logger.With(zap.String("ocppVersion", ocppVersion.String()), zap.Int("messages", len(messages)))
122+
logger.Info("Parsing and validating messages")
123+
124+
// Parse the messages
125+
parserResults, nonParsedMessages, err := s.parser.Parse(messages)
126+
if err != nil {
127+
return nil, errors.Wrap(err, "failed to parse messages")
128+
}
129+
130+
// Add non-parsable messages to the aggregator
131+
for line, result := range nonParsedMessages {
132+
s.aggregator.AddNonParsableMessage(line, result)
133+
}
134+
135+
// Add parsed messages to the aggregator
136+
for messageId, result := range parserResults {
137+
// Validate the request
138+
_, found := result.GetRequest()
139+
if found {
140+
s.aggregator.AddParserResult(messageId, true, result.Request)
141+
}
142+
143+
_, found = result.GetResponse()
144+
if found {
145+
s.aggregator.AddParserResult(messageId, false, result.Response)
146+
}
147+
}
148+
149+
// Only valid messages should be validated further
150+
validMessages := s.filterValidMessages(parserResults)
151+
invalidMessagesCount := len(parserResults) - len(validMessages)
152+
logger.Info(
153+
"✅ OCPP messages parsed. Proceeding with validation.",
154+
zap.Int("invalid_messages", invalidMessagesCount),
155+
zap.Int("unparsable_messages", len(nonParsedMessages)),
156+
)
157+
158+
for messageId, parserResult := range validMessages {
159+
// Validate the request
160+
request, found := parserResult.GetRequest()
161+
if found {
162+
result, err := s.validator.ValidateMessage(ocppVersion, request)
163+
if err != nil {
164+
return nil, errors.Wrap(err, "failed to validate request message")
165+
}
166+
167+
// Store the results in the aggregator
168+
s.aggregator.AddValidationResults(messageId, true, *result)
169+
}
170+
171+
// Validate the response
172+
response, found := parserResult.GetResponse()
173+
if found {
174+
result, err := s.validator.ValidateMessage(ocppVersion, response)
175+
if err != nil {
176+
return nil, errors.Wrap(err, "failed to validate response message")
177+
}
178+
179+
// Store the results in the aggregator
180+
s.aggregator.AddValidationResults(messageId, false, *result)
181+
}
182+
}
183+
184+
validationReport := s.aggregator.CreateReport()
185+
return &validationReport, nil
186+
}
187+
188+
// getMessagesFromFile reads messages from a file, where each message is separated by a newline character.
189+
func (s *Service) getMessagesFromFile(file string) ([]string, error) {
190+
s.logger.Debug("Reading file", zap.String("file", file))
191+
192+
openFile, err := os.OpenFile(file, os.O_RDONLY, 0666)
193+
if err != nil {
194+
return nil, errors.Wrap(err, "failed to open file")
195+
}
196+
197+
content, err := io.ReadAll(openFile)
198+
if err != nil {
199+
return nil, errors.Wrap(err, "failed to read file content")
200+
}
201+
202+
messages := strings.Split(string(content), "\n")
203+
return messages, nil
204+
}
205+
206+
// filterValidMessages filters out invalid messages from the parser results.
207+
func (s *Service) filterValidMessages(parserResults map[string]parser.RequestResponseResult) map[string]parser.RequestResponseResult {
208+
validMessages := make(map[string]parser.RequestResponseResult)
209+
210+
for messageUniqueId, parserResult := range parserResults {
211+
if !parserResult.IsValid() {
212+
continue
213+
}
214+
validMessages[messageUniqueId] = parserResult
215+
}
216+
217+
return validMessages
218+
}

0 commit comments

Comments
 (0)