Small, unrelated except that each was raised on #479. compileObjectSchemas parsed every non-directory file under schemas/ as a JSON Schema, so a stray editor backup or .DS_Store would panic the server at startup through MustLoad. schema_ref can only name a .json file; anything else is skipped. cmd/silo used MustLoad while the ETag check beside it and every other startup failure use log.Fatalf. It now fails the same way, so a bad contract prints an error instead of a stack trace. TestRegistryDefaultsMatchTheContract called scalarDefault before handling null, and scalarDefault rejects null as non-scalar — so the subtest skipped and the comparison after it was unreachable. A nullable contract default could disagree with a non-empty registry default and nothing failed. Confirmed by injecting that drift, which now reports it. The three appearance providers each adapted the auth context to AppearanceAuth with identical code, putting the shape of auth back in three places that widening cache ownership would have to find. useAppearanceCacheOwner now does it once. useTheme.test.ts cleared storage.KEYS between cases, but appearanceCache writes namespaced keys and an owner pointer that are not in that list, so both survived and the suite was order-dependent. It clears the store, as storage.test.ts already did. The abs_smart_collection_store comment is reworded rather than given back its SQL quotes: gofmt folds a pair of apostrophes in a doc comment into a typographic quote, which is how it became one in the first place. Reported by CodeRabbit review on #479.
223 lines
6.5 KiB
Go
223 lines
6.5 KiB
Go
package settingscontract
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
|
|
|
settingsv1 "github.com/Silo-Server/silo-server/contracts/settings/v1"
|
|
)
|
|
|
|
// contractFS is the embedded canonical contract. Clients vendor a pinned copy
|
|
// of the same files.
|
|
var contractFS = settingsv1.FS
|
|
|
|
const (
|
|
manifestPath = "manifest.json"
|
|
manifestSchemaPath = "manifest.schema.json"
|
|
schemasDir = "schemas"
|
|
)
|
|
|
|
var (
|
|
loadOnce sync.Once
|
|
loaded *Manifest
|
|
loadedErr error
|
|
loadedRaw []byte
|
|
loadedSchemas map[string][]byte
|
|
objSchemas map[string]*jsonschema.Schema
|
|
)
|
|
|
|
// loaded contract, as returned by load(). Keeping this a value rather than
|
|
// having load() assign the package globals means load() stays pure and can be
|
|
// called with a test filesystem without clobbering the process-wide contract.
|
|
type contract struct {
|
|
manifest *Manifest
|
|
raw []byte
|
|
schemaRaw map[string][]byte
|
|
compiled map[string]*jsonschema.Schema
|
|
}
|
|
|
|
// Load returns the embedded canonical manifest, parsed and fully validated.
|
|
//
|
|
// It is loaded once per process. A malformed or self-inconsistent manifest is a
|
|
// build-time defect, not a runtime condition: the contract tests fail on it, and
|
|
// callers that reach this at runtime should treat the error as fatal at startup
|
|
// rather than degrading.
|
|
func Load() (*Manifest, error) {
|
|
loadOnce.Do(func() {
|
|
result, err := load(contractFS)
|
|
if err != nil {
|
|
loadedErr = err
|
|
return
|
|
}
|
|
loaded = result.manifest
|
|
loadedRaw = result.raw
|
|
loadedSchemas = result.schemaRaw
|
|
objSchemas = result.compiled
|
|
})
|
|
return loaded, loadedErr
|
|
}
|
|
|
|
// MustLoad returns the embedded manifest or panics. For use in server startup
|
|
// where a broken embedded contract cannot be recovered from.
|
|
func MustLoad() *Manifest {
|
|
m, err := Load()
|
|
if err != nil {
|
|
panic(fmt.Sprintf("settingscontract: embedded manifest is invalid: %v", err))
|
|
}
|
|
return m
|
|
}
|
|
|
|
// RawBytes returns the embedded manifest file exactly as checked in.
|
|
func RawBytes() ([]byte, error) {
|
|
if _, err := Load(); err != nil {
|
|
return nil, err
|
|
}
|
|
return append([]byte(nil), loadedRaw...), nil
|
|
}
|
|
|
|
// SchemaBytes returns every value schema file exactly as checked in, keyed by
|
|
// file name. These decide which object-typed values the contract accepts, so
|
|
// they are part of its identity — see ETag.
|
|
func SchemaBytes() (map[string][]byte, error) {
|
|
if _, err := Load(); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string][]byte, len(loadedSchemas))
|
|
for name, body := range loadedSchemas {
|
|
out[name] = append([]byte(nil), body...)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ObjectSchema returns the compiled JSON Schema for an object-typed value.
|
|
func ObjectSchema(ref string) (*jsonschema.Schema, bool) {
|
|
if _, err := Load(); err != nil {
|
|
return nil, false
|
|
}
|
|
schema, ok := objSchemas[ref]
|
|
return schema, ok
|
|
}
|
|
|
|
func load(fsys fs.FS) (contract, error) {
|
|
raw, err := fs.ReadFile(fsys, manifestPath)
|
|
if err != nil {
|
|
return contract{}, fmt.Errorf("reading manifest: %w", err)
|
|
}
|
|
|
|
if err := validateAgainstManifestSchema(fsys, raw); err != nil {
|
|
return contract{}, err
|
|
}
|
|
|
|
var manifest Manifest
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&manifest); err != nil {
|
|
return contract{}, fmt.Errorf("parsing manifest: %w", err)
|
|
}
|
|
|
|
if err := manifest.index(); err != nil {
|
|
return contract{}, err
|
|
}
|
|
|
|
schemaRaw, compiled, err := compileObjectSchemas(fsys)
|
|
if err != nil {
|
|
return contract{}, err
|
|
}
|
|
|
|
if err := manifest.Validate(compiled); err != nil {
|
|
return contract{}, err
|
|
}
|
|
|
|
return contract{manifest: &manifest, raw: raw, schemaRaw: schemaRaw, compiled: compiled}, nil
|
|
}
|
|
|
|
// validateAgainstManifestSchema checks the manifest file against its own JSON
|
|
// Schema before Go decoding, so shape errors report as schema violations with a
|
|
// location rather than as opaque unmarshal failures.
|
|
func validateAgainstManifestSchema(fsys fs.FS, raw []byte) error {
|
|
schemaBytes, err := fs.ReadFile(fsys, manifestSchemaPath)
|
|
if err != nil {
|
|
return fmt.Errorf("reading manifest schema: %w", err)
|
|
}
|
|
|
|
schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaBytes))
|
|
if err != nil {
|
|
return fmt.Errorf("parsing manifest schema: %w", err)
|
|
}
|
|
|
|
compiler := jsonschema.NewCompiler()
|
|
if err := compiler.AddResource(manifestSchemaPath, schemaDoc); err != nil {
|
|
return fmt.Errorf("registering manifest schema: %w", err)
|
|
}
|
|
schema, err := compiler.Compile(manifestSchemaPath)
|
|
if err != nil {
|
|
return fmt.Errorf("compiling manifest schema: %w", err)
|
|
}
|
|
|
|
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return fmt.Errorf("parsing manifest: %w", err)
|
|
}
|
|
if err := schema.Validate(doc); err != nil {
|
|
return fmt.Errorf("manifest does not satisfy manifest.schema.json: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// compileObjectSchemas compiles every schema under schemas/ so object-typed
|
|
// values and their defaults can be validated. Compiling all of them up front
|
|
// also catches a malformed schema file that no definition happens to reference
|
|
// yet.
|
|
func compileObjectSchemas(fsys fs.FS) (map[string][]byte, map[string]*jsonschema.Schema, error) {
|
|
entries, err := fs.ReadDir(fsys, schemasDir)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("reading value schema directory: %w", err)
|
|
}
|
|
|
|
compiler := jsonschema.NewCompiler()
|
|
raw := make(map[string][]byte, len(entries))
|
|
names := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
// schema_ref can only name a .json file, so anything else here is not a
|
|
// value schema. Parsing it anyway would turn a stray file — an editor
|
|
// backup, a .DS_Store — into a panic at startup via MustLoad.
|
|
if !strings.HasSuffix(name, ".json") {
|
|
continue
|
|
}
|
|
body, err := fs.ReadFile(fsys, path.Join(schemasDir, name))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("reading value schema %s: %w", name, err)
|
|
}
|
|
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("parsing value schema %s: %w", name, err)
|
|
}
|
|
if err := compiler.AddResource(name, doc); err != nil {
|
|
return nil, nil, fmt.Errorf("registering value schema %s: %w", name, err)
|
|
}
|
|
raw[name] = body
|
|
names = append(names, name)
|
|
}
|
|
|
|
compiled := make(map[string]*jsonschema.Schema, len(names))
|
|
for _, name := range names {
|
|
schema, err := compiler.Compile(name)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("compiling value schema %s: %w", name, err)
|
|
}
|
|
compiled[name] = schema
|
|
}
|
|
return raw, compiled, nil
|
|
}
|