Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions notecard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
Expand All @@ -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{
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 40 additions & 8 deletions notecard/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading