diff --git a/notecard/main.go b/notecard/main.go index 9d5331f..bd68f91 100644 --- a/notecard/main.go +++ b/notecard/main.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "os/signal" + "strconv" "strings" "syscall" "time" @@ -30,9 +31,45 @@ var card *notecard.Context var version = "development" // JSON schema control variables -var validateJSON bool = false +var validateJSON bool = true var jsonSchemaUrl string = "https://github.com/blues/notecard-schema/releases/latest/download/notecard.api.json" +// skipValidationFile persists a validation skip expiry across invocations +const skipValidationFile = "/tmp/notecard-skip-validation" + +// skipFlag implements flag.Value with optional numeric value. +// Used as -skip (skip this invocation) or -skip=N (skip for N hours). +type skipFlag struct { + set bool + hours int +} + +func (s *skipFlag) String() string { + if !s.set { + return "" + } + if s.hours == 0 { + return "true" + } + return strconv.Itoa(s.hours) +} + +func (s *skipFlag) Set(val string) error { + s.set = true + if val == "true" { + s.hours = 0 + return nil + } + n, err := strconv.Atoi(val) + if err != nil || n < 1 { + return fmt.Errorf("value must be a positive number of hours") + } + s.hours = n + return nil +} + +func (s *skipFlag) IsBoolFlag() bool { return true } + // getFlagGroups returns the organized flag groups func getFlagGroups() []lib.FlagGroup { return []lib.FlagGroup{ @@ -67,6 +104,7 @@ func getFlagGroups() []lib.FlagGroup { lib.GetFlagByName("pretty"), lib.GetFlagByName("req"), lib.GetFlagByName("dry"), + lib.GetFlagByName("skip"), lib.GetFlagByName("input"), lib.GetFlagByName("output"), lib.GetFlagByName("fast"), @@ -163,7 +201,6 @@ func main() { }() // 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 @@ -181,6 +218,8 @@ func main() { flag.StringVar(&actionRequest, "req", "", "perform the specified request (in quotes)") var actionRequestDry bool flag.BoolVar(&actionRequestDry, "dry", false, "validate a -req but do not send it to the Notecard") + var actionSkip skipFlag + flag.Var(&actionSkip, "skip", "skip JSON schema validation for this request, or for N hours if a number is provided") var actionWhenConnected bool flag.BoolVar(&actionWhenConnected, "when-connected", false, "wait until connected") var actionWhenDisconnected bool @@ -262,6 +301,21 @@ func main() { fmt.Printf("%s\n", err) exitFailAndCloseCard() } + if actionSkip.set { + validateJSON = false + if actionSkip.hours > 0 { + expiry := time.Now().Add(time.Duration(actionSkip.hours) * time.Hour) + os.WriteFile(skipValidationFile, []byte(strconv.FormatInt(expiry.Unix(), 10)), 0644) + } + } else if data, err := os.ReadFile(skipValidationFile); err == nil { + if expiry, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64); err == nil { + if time.Now().Unix() < expiry { + validateJSON = false + } else { + os.Remove(skipValidationFile) + } + } + } config, err := lib.GetConfig() if err != nil { diff --git a/notecard/validate.go b/notecard/validate.go index e210dd3..981b0f5 100644 --- a/notecard/validate.go +++ b/notecard/validate.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "crypto/sha256" "encoding/json" "fmt" "io" @@ -10,6 +11,7 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/santhosh-tekuri/jsonschema/v5" _ "github.com/santhosh-tekuri/jsonschema/v5/httploader" // Enable HTTP/HTTPS loading @@ -25,6 +27,9 @@ var ( // cacheDir is the directory where schemas are stored const cacheDir = "/tmp/notecard-schema/" +// schemaCacheTTL is how long before a cached schema is eligible for background refresh +const schemaCacheTTL = 24 * time.Hour + // extractRefs recursively extracts $ref URLs from a schema func extractRefs(schema map[string]interface{}, baseURL string) []string { var refs []string @@ -203,28 +208,55 @@ func initSchema(url string, verbose bool) error { return schemaErr } -// loadOrFetchSchema loads a schema from cache or fetches it from the URL, caching the result +// loadOrFetchSchema loads a schema from cache or fetches it from the URL, caching the result. +// If the cache is stale (older than schemaCacheTTL), the cached version is returned immediately +// and a background refresh is triggered to update the cache without blocking the caller. 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 { - defer file.Close() - data, err := io.ReadAll(file) + if info, err := os.Stat(cachePath); err == nil { + data, err := os.ReadFile(cachePath) if err != nil { return nil, fmt.Errorf("failed to read cached schema %s: %v", cachePath, err) } - // Verify it's valid JSON var v interface{} if err := json.Unmarshal(data, &v); err != nil { - // Invalid cache: proceed to fetch + // Invalid cache: fetch synchronously return fetchAndCacheSchema(url, verbose) } + if time.Since(info.ModTime()) > schemaCacheTTL { + go refreshSchemaCache(url, cachePath, data) + } return bytes.NewReader(data), nil } - // Cache miss: fetch from URL + // Cache miss: fetch synchronously return fetchAndCacheSchema(url, verbose) } +// refreshSchemaCache fetches the schema in the background, compares it to the cached version, +// and updates the cache only if the content has changed. If unchanged, the cache mtime is +// bumped to defer the next check by another TTL period. +func refreshSchemaCache(url string, cachePath string, cached []byte) { + resp, err := http.Get(url) + if err != nil || resp.StatusCode != http.StatusOK { + return + } + defer resp.Body.Close() + newData, err := io.ReadAll(resp.Body) + if err != nil { + return + } + var v interface{} + if err := json.Unmarshal(newData, &v); err != nil { + return + } + if sha256.Sum256(newData) == sha256.Sum256(cached) { + now := time.Now() + os.Chtimes(cachePath, now, now) + return + } + os.WriteFile(cachePath, newData, 0644) +} + func resolveSchemaError(reqMap map[string]interface{}, verbose bool) (err error) { reqType := reqMap["req"] if reqType == nil {