diff --git a/lib/config.go b/lib/config.go index f240130..de9b7df 100644 --- a/lib/config.go +++ b/lib/config.go @@ -18,7 +18,7 @@ import ( "github.com/blues/note-go/notehub" ) -// ConfigCreds are the credentials for a given notehub +// ConfigCreds are the credentials for a given Notehub type ConfigCreds struct { User string `json:"user,omitempty"` Token string `json:"token,omitempty"` @@ -37,7 +37,6 @@ type ConfigSettings struct { HubCreds map[string]ConfigCreds `json:"creds,omitempty"` Interface string `json:"interface,omitempty"` IPort map[string]ConfigPort `json:"iport,omitempty"` - SchemaUrl string `json:"json-schema-url,omitempty"` } // Config are the master config settings @@ -46,7 +45,6 @@ var configFlagHub string var configFlagInterface string var configFlagPort string var configFlagPortConfig int -var configFlagJsonSchemaUrl string // ConfigRead reads the current info from config file func ConfigRead() error { @@ -105,7 +103,6 @@ func ConfigReset() { configResetInterface() ConfigSetHub("-") Config.When = time.Now().UTC().Format("2006-01-02T15:04:05Z") - Config.SchemaUrl = "" } // ConfigShow displays all current config parameters @@ -138,9 +135,6 @@ func ConfigShow() error { fmt.Printf(" -portconfig %d\n", Config.IPort[Config.Interface].PortConfig) } } - if Config.SchemaUrl != "" { - fmt.Printf(" -json-schema-url %s\n", Config.SchemaUrl) - } return nil @@ -174,11 +168,6 @@ func ConfigFlagsProcess() (err error) { } else if configFlagInterface != "" { Config.Interface = configFlagInterface } - if configFlagJsonSchemaUrl == "-" { - Config.SchemaUrl = "" - } else if configFlagJsonSchemaUrl != "" { - Config.SchemaUrl = configFlagJsonSchemaUrl - } if configFlagPort == "-" { temp := Config.IPort[Config.Interface] temp.Port = "" @@ -212,13 +201,12 @@ func ConfigFlagsRegister(notecardFlags bool, notehubFlags bool) { // Process the commands if notecardFlags { - flag.StringVar(&configFlagInterface, "interface", "", "select 'serial' or 'i2c' interface for notecard") - flag.StringVar(&configFlagJsonSchemaUrl, "json-schema-url", "", "set the schema URL for the notecard") - flag.StringVar(&configFlagPort, "port", "", "select serial or i2c port for notecard") - flag.IntVar(&configFlagPortConfig, "portconfig", 0, "set serial device speed or i2c address for notecard") + flag.StringVar(&configFlagInterface, "interface", "", "select 'serial' or 'i2c' interface for Notecard") + flag.StringVar(&configFlagPort, "port", "", "select serial or i2c port for Notecard") + flag.IntVar(&configFlagPortConfig, "portconfig", 0, "set serial device speed or i2c address for Notecard") } if notehubFlags { - flag.StringVar(&configFlagHub, "hub", "", "set notehub domain") + flag.StringVar(&configFlagHub, "hub", "", "set Notehub domain") } } @@ -250,7 +238,6 @@ func FlagParse(notecardFlags bool, notehubFlags bool) (err error) { case "-interface": case "-port": case "-portconfig": - case "-json-schema-url": case "-hub": // any odd argument that isn't one of our switches default: @@ -271,11 +258,6 @@ func FlagParse(notecardFlags bool, notehubFlags bool) (err error) { Config.Interface = str } - str = os.Getenv("NOTE_JSON_SCHEMA_URL") - if str != "" { - Config.SchemaUrl = str - } - // Override via env vars if specified str = os.Getenv("NOTE_PORT") if str != "" { @@ -332,7 +314,7 @@ func ConfigAuthenticationHeader(httpReq *http.Request) (err error) { if hub == "" { hub = notehub.DefaultAPIService } - err = fmt.Errorf("not authenticated to %s: please use 'notehub -signin' to sign into the notehub service", hub) + err = fmt.Errorf("not authenticated to %s: please use 'notehub -signin' to sign into the Notehub service", hub) return } diff --git a/notecard/main.go b/notecard/main.go index fec4ad7..2a06202 100644 --- a/notecard/main.go +++ b/notecard/main.go @@ -29,6 +29,10 @@ var card *notecard.Context // CLI Version - Set by ldflags during build/release var version = "development" +// JSON schema control variables +var validateJSON bool = false +var jsonSchemaUrl string = "https://raw.githubusercontent.com/blues/notecard-schema/master/notecard.api.json" + // getFlagGroups returns the organized flag groups func getFlagGroups() []lib.FlagGroup { return []lib.FlagGroup{ @@ -66,7 +70,6 @@ func getFlagGroups() []lib.FlagGroup { lib.GetFlagByName("output"), lib.GetFlagByName("fast"), lib.GetFlagByName("trace"), - lib.GetFlagByName("force"), }, }, { @@ -108,7 +111,6 @@ func getFlagGroups() []lib.FlagGroup { lib.GetFlagByName("interface"), lib.GetFlagByName("port"), lib.GetFlagByName("portconfig"), - lib.GetFlagByName("json-schema-url"), }, }, { @@ -144,6 +146,13 @@ func main() { os.Exit(exitFail) }() + // Check the environment for JSON schema control variables + _, validateJSON = os.LookupEnv("BLUES") // Opt-in Blues employees to validation + url := os.Getenv("NOTE_JSON_SCHEMA_URL") // Override the default schema URL + if url != "" { + jsonSchemaUrl = url + } + // Override the default usage function to use our grouped format flag.Usage = func() { lib.PrintGroupedFlags(getFlagGroups(), "notecard") @@ -162,8 +171,6 @@ func main() { flag.BoolVar(&actionWhenDisarmed, "when-disarmed", false, "wait until ATTN is disarmed") var actionVerbose bool flag.BoolVar(&actionVerbose, "verbose", false, "display Notecard requests and responses") - var actionForce bool - flag.BoolVar(&actionForce, "force", false, "bypass JSON request validation against the Notecard schema (when used with -req)") var actionWhenSynced bool flag.BoolVar(&actionWhenSynced, "when-synced", false, "sync if needed and wait until sync completed") var actionReserved bool @@ -678,48 +685,41 @@ func main() { actionRequest = "" } - // If the user has provided a JSON schema URL, we need to clear the cache - // and re-initialize the schema. This is because the schema URL may have - // changed, and we need to make sure that the schema is up to date. - json_provided := false - for _, arg := range os.Args { - if arg == "-json-schema-url" { - json_provided = true - break - } - } - if err == nil && json_provided { - clearCache() - url := lib.Config.SchemaUrl - if url == "" { - url = defaultJsonSchemaUrl - } - err = initSchema(url) - } - if err == nil && actionRequest != "" { var rspJSON []byte var req, rsp notecard.Request note.JSONUnmarshal([]byte(actionRequest), &req) - if !actionForce { - err = validateRequest([]byte(actionRequest), lib.Config.SchemaUrl) - if err != nil { - goto done - } - } - - // If we want to read the payload from a file, do so + // Append payload from the specified file if actionInput != "" { var contents []byte contents, err = os.ReadFile(actionInput) if err == nil { req.Payload = &contents + + // Update the original request with the payload + var reqBytes []byte + reqBytes, err = note.JSONMarshal(req) + if err == nil { + actionRequest = string(reqBytes) + } + } + } + + // Validate the request against the schema + if err == nil && validateJSON { + var reqMap map[string]interface{} + err = note.JSONUnmarshal([]byte(actionRequest), &reqMap) + if err == nil { + validationErr := validateRequest(reqMap, jsonSchemaUrl, actionVerbose) + if validationErr != nil { + fmt.Fprintf(os.Stderr, "warning: %s\n", validationErr) + } } } // Perform the transaction and do special handling for binary - if req.Req == "card.binary.get" { + if err == nil && req.Req == "card.binary.get" { expectedMD5 := req.Status rsp, err = card.TransactionRequest(req) if err == nil { @@ -739,7 +739,7 @@ func main() { } } } - } else if req.Req == "card.binary.put" && (req.Body == nil || len(*req.Body) == 0) { + } else if err == nil && req.Req == "card.binary.put" && (req.Body == nil || len(*req.Body) == 0) { payload := *req.Payload actualMD5 := fmt.Sprintf("%x", md5.Sum(payload)) if req.Status != "" && !strings.EqualFold(req.Status, actualMD5) { @@ -757,7 +757,8 @@ func main() { } } } - } else { + } else if err == nil { + // Transact using CLI input to avoid JSON parsing complications actionRequest = strings.ReplaceAll(actionRequest, "\\n", "\n") rspJSON, err = card.TransactionJSON([]byte(actionRequest)) if err == nil { @@ -766,25 +767,23 @@ func main() { } // Write the payload to an output file if appropriate - if err == nil && actionOutput != "" { - if rsp.Payload != nil { - err = os.WriteFile(actionOutput, *rsp.Payload, 0644) - if err != nil { - rsp.Payload = nil - } + if err == nil && actionOutput != "" && rsp.Payload != nil { + err = os.WriteFile(actionOutput, *rsp.Payload, 0644) + // If we can't write the file, set the payload to nil so + // we don't try to print it out and cause a JSON error. + if err != nil { + rsp.Payload = nil } } // Output the response to the console - if !actionVerbose { - if err == nil { - if actionPretty { - rspJSON, _ = note.JSONMarshalIndent(rsp, "", " ") - } else { - rspJSON, _ = note.JSONMarshal(rsp) - } - fmt.Printf("%s\n", rspJSON) + if err == nil && !actionVerbose { + if actionPretty { + rspJSON, _ = note.JSONMarshalIndent(rsp, "", " ") + } else { + rspJSON, _ = note.JSONMarshal(rsp) } + fmt.Printf("%s\n", rspJSON) } } @@ -854,7 +853,6 @@ func main() { err = explore(actionReserved, actionPretty) } -done: // Process errors if err != nil { if actionRequest != "" && !actionVerbose { diff --git a/notecard/validate.go b/notecard/validate.go index 30aca07..f034e2e 100644 --- a/notecard/validate.go +++ b/notecard/validate.go @@ -11,7 +11,6 @@ import ( "strings" "sync" - "github.com/blues/note-go/note" "github.com/santhosh-tekuri/jsonschema/v5" _ "github.com/santhosh-tekuri/jsonschema/v5/httploader" // Enable HTTP/HTTPS loading ) @@ -24,12 +23,7 @@ var ( ) // cacheDir is the directory where schemas are stored -const cacheDir = "./notecard-schema" -const defaultJsonSchemaUrl = "https://raw.githubusercontent.com/blues/notecard-schema/master/notecard.api.json" - -func clearCache() error { - return os.RemoveAll(cacheDir) -} +const cacheDir = "/tmp/notecard-schema/" // extractRefs recursively extracts $ref URLs from a schema func extractRefs(schema map[string]interface{}, baseURL string) []string { @@ -53,7 +47,11 @@ func extractRefs(schema map[string]interface{}, baseURL string) []string { } // fetchAndCacheSchema fetches a schema from the URL and caches it -func fetchAndCacheSchema(url string) (io.Reader, error) { +func fetchAndCacheSchema(url string, verbose bool) (io.Reader, error) { + // Fetch the schema + if verbose { + fmt.Fprintf(os.Stderr, "*** fetching schema: %s ***\n", url) + } resp, err := http.Get(url) if err != nil { return nil, fmt.Errorf("failed to fetch schema %s: %v", url, err) @@ -69,17 +67,71 @@ func fetchAndCacheSchema(url string) (io.Reader, error) { // Verify it's valid JSON before caching var v interface{} if err := json.Unmarshal(data, &v); err != nil { - return nil, fmt.Errorf("invalid schema %s: %v", url, err) + return nil, fmt.Errorf("invalid JSON schema %s: %v", url, err) } // Save to cache cachePath := getCachePath(url) if err := os.WriteFile(cachePath, data, 0644); err != nil { // Log error but continue - fmt.Fprintf(os.Stderr, "Failed to cache schema %s: %v\n", url, err) + if verbose { + fmt.Fprintf(os.Stderr, "failed to cache schema %s: %v\n", url, err) + } } return bytes.NewReader(data), nil } +func formatErrorMessage(reqType string, errUnformatted error) (err error) { + errMsg := errUnformatted.Error() + + // Define constants + const prefix = "jsonschema: '" + const mid1 = "' does not validate with " + const mid2 = ": " + + // Check if message starts with prefix + if !strings.HasPrefix(errMsg, prefix) { + return fmt.Errorf("invalid error message format") + } + + // Remove prefix and split on mid1 + rest := strings.TrimPrefix(errMsg, prefix) + parts := strings.SplitN(rest, mid1, 2) + if len(parts) != 2 { + return fmt.Errorf("invalid error message format") + } + + // Extract property and remaining part + property := parts[0] + if len(property) > 0 { + // As of jsonschema v5.3.1, a forward-slash is prefixed to the + // property name. Remove it to improve readability. + // Workaround for issue: + // https://github.com/santhosh-tekuri/jsonschema/issues/220 + property = parts[0][1:] + } + remaining := parts[1] + + // Split remaining part on mid2 + finalParts := strings.SplitN(remaining, mid2, 2) + if len(finalParts) != 2 { + return fmt.Errorf("invalid error message format") + } + + // Extract schema rule and error message + // schemaRule := finalParts[0] // Not used in output, but available if needed + errorMessage := finalParts[1] + + // Format the new error message + if len(property) > 0 { + err = fmt.Errorf("'%s' is not valid for %s: %s", property, reqType, errorMessage) + } else { + err = fmt.Errorf("for '%s' %s", reqType, errorMessage) + } + + // Return the formatted error + return err +} + // getCachePath converts a URL to a safe file path in the cache directory func getCachePath(url string) string { // Use the URL path as the filename, replacing invalid characters @@ -88,21 +140,21 @@ func getCachePath(url string) string { } // initSchema compiles the schema, using cached files if available -func initSchema(url string) error { +func initSchema(url string, verbose bool) error { schemaOnce.Do(func() { compiler := jsonschema.NewCompiler() compiler.Draft = jsonschema.Draft2020 // Ensure cache directory exists if err := os.MkdirAll(cacheDir, 0755); err != nil { - schemaErr = fmt.Errorf("failed to create cache directory: %v", err) + schemaErr = fmt.Errorf("failed to create cache directory %s: %v", cacheDir, err) return } // Load main schema - mainSchemaReader, err := loadOrFetchSchema(url) + mainSchemaReader, err := loadOrFetchSchema(url, verbose) if err != nil { - schemaErr = fmt.Errorf("failed to load schema %s: %v", url, err) + schemaErr = fmt.Errorf("failed to load main schema %s: %v", url, err) return } // Read main schema to extract $ref URLs @@ -118,13 +170,13 @@ func initSchema(url string) error { } // Add main schema resource if err := compiler.AddResource(url, bytes.NewReader(mainSchemaData)); err != nil { - schemaErr = fmt.Errorf("failed to add schema resource %s: %v", url, err) + schemaErr = fmt.Errorf("failed to add main schema resource %s: %v", url, err) return } // Extract and cache referenced schemas refs := extractRefs(mainSchema, url) for _, refURL := range refs { - refReader, err := loadOrFetchSchema(refURL) + refReader, err := loadOrFetchSchema(refURL, verbose) if err != nil { schemaErr = fmt.Errorf("failed to load referenced schema %s: %v", refURL, err) return @@ -138,7 +190,7 @@ func initSchema(url string) error { // Compile the schema schema, err = compiler.Compile(url) if err != nil { - schemaErr = fmt.Errorf("failed to compile schema: %v (use -force to bypass validation)", err) + schemaErr = fmt.Errorf("failed to compile schema %s: %v", url, err) return } }) @@ -146,7 +198,7 @@ func initSchema(url string) error { } // loadOrFetchSchema loads a schema from cache or fetches it from the URL, caching the result -func loadOrFetchSchema(url string) (io.Reader, error) { +func loadOrFetchSchema(url string, verbose bool) (io.Reader, error) { cachePath := getCachePath(url) // Try to load from cache if file, err := os.Open(cachePath); err == nil { @@ -159,33 +211,48 @@ func loadOrFetchSchema(url string) (io.Reader, error) { var v interface{} if err := json.Unmarshal(data, &v); err != nil { // Invalid cache: proceed to fetch - return fetchAndCacheSchema(url) + return fetchAndCacheSchema(url, verbose) } return bytes.NewReader(data), nil } // Cache miss: fetch from URL - return fetchAndCacheSchema(url) + return fetchAndCacheSchema(url, verbose) } -func validateRequest(requestJSON []byte, url string) error { - // Use default URL if none provided - if url == "" { - url = defaultJsonSchemaUrl +func resolveSchemaError(reqMap map[string]interface{}, verbose bool) (err error) { + reqType := reqMap["req"] + if reqType == nil { + reqType = reqMap["cmd"] } - - // Ensure schema is initialized - if err := initSchema(url); err != nil { - return err + reqTypeStr, ok := reqType.(string) + if !ok { + err = fmt.Errorf("request type not a string") + } else if reqTypeStr == "" { + err = fmt.Errorf("no request type specified") + } else { + var reqSchema *jsonschema.Schema + reqSchema, err = jsonschema.Compile(filepath.Join(cacheDir, reqTypeStr+".req.notecard.api.json")) + if err == nil { + err = reqSchema.Validate(reqMap) + if !verbose { + err = formatErrorMessage(reqTypeStr, err) + } + } } + return err +} - var reqMap map[string]interface{} - if err := note.JSONUnmarshal(requestJSON, &reqMap); err != nil { - return fmt.Errorf("failed to parse request for validation: %v (use -force to bypass validation)", err) +func validateRequest(reqMap map[string]interface{}, url string, verbose bool) (err error) { + // Ensure schema is initialized + if err = initSchema(url, verbose); err != nil { + return fmt.Errorf("failed to initialize schema: %v", err) } - if err := schema.Validate(reqMap); err != nil { - return fmt.Errorf("validation failed: %v (use -force to bypass validation)", err) + // Validate the request against the schema + if err = schema.Validate(reqMap); err != nil { + return resolveSchemaError(reqMap, verbose) } + // Validates successfully return nil }