Files

237 lines
7.0 KiB
Go
Raw Permalink Normal View History

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
}