237 lines
7.0 KiB
Go
237 lines
7.0 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
|
||
|
|
}
|
||
|
|
|
||
|
|
// ObjectSchemas returns every compiled object schema, keyed by schema_ref.
|
||
|
|
//
|
||
|
|
// ValidateValue needs the whole set rather than one entry, and it is called
|
||
|
|
// from outside this package now — the migration planner and the mutation
|
||
|
|
// endpoint both validate values they did not author. Returning the live map
|
||
|
|
// rather than a copy matches ValidateValue's own signature; callers treat it as
|
||
|
|
// read-only, and it is built once at load.
|
||
|
|
func ObjectSchemas() map[string]*jsonschema.Schema {
|
||
|
|
if _, err := Load(); err != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return objSchemas
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|