schematics

package module
v1.0.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 25, 2026 License: MIT Imports: 21 Imported by: 0

README

json-schematics-v2

Go Reference Go Report Card CI MIT License

Validate and transform arbitrary JSON in Go using a small, declarative schema — with no third-party dependencies.

📖 Full documentation, live examples, and an interactive playground: jsonschematics.ashbeelghouri.com

json-schematics-v2 flattens any document into dotted keys (user.profile.name, tags.0), lets each schema field target one or more of those keys (literally, with a * wildcard, or with a regular expression), and runs every matched value through an ordered chain of validators and operators.

This is a ground-up redesign of the original jsonschematics. See MIGRATION.md for what changed and why.

Features

  • 🎯 Target anything — match flattened keys literally, with a * wildcard, or a full regex.
  • 🧩 Batteries included — 56 validators, 18 operators, and 7 conditions built in.
  • 🔒 Concurrency-safe — one *Schematics (or *API) can be built once and shared across goroutines; compiled matchers are cached, so per-request validation is allocation-light.
  • 🏗️ Fluent builder — define schemas in Go with NewSchema().Field(...).Required().Email(), so a mistyped rule or target is a compile error, not a runtime surprise.
  • 🛡️ Never panics — wrong types return typed errors; unknown rule names are caught up front by Check().
  • 🔍 Catches target typosValidateSchema() matches every field's target against sample data, so a typo like "target": "mane" fails your tests instead of silently matching nothing in production.
  • 🧪 Type-aware — opt into WithTypeChecks() and a field's type is enforced; WithCollectAll() reports every failure per field.
  • 🌍 Localized errors — per-locale messages and format templates; ValidationError marshals to clean JSON.
  • 🏷️ Stable error codes — every failure carries a machine-readable code (rule.minLength, field.required) that survives rewording and translation. See CODES.md.
  • 📍 Drop-in for HTTP APIs — each error exposes an RFC 6901 JSON Pointer, and one call writes an RFC 7807 application/problem+json response.
  • 🔭 Observable — an optional observer hook reports per-rule outcomes and per-validation timings, with adapters for slog, Prometheus-shaped metrics, and OpenTelemetry spans — none of them a dependency, and none of them costing anything when unused.
  • ⚙️ Validate and transform — a separate operator pass, plus a shared context DB for cross-field logic.
  • 🌐 HTTP request validation — validate headers, query, and body per endpoint, with a body-size cap.
  • 📦 Zero dependencies — standard library only, Go 1.24+.

Install

go get github.com/ashbeelghouri/json-schematics-v2@latest
import schematics "github.com/ashbeelghouri/json-schematics-v2"

Quick start

s := schematics.New()
if err := s.LoadFile("schema.json"); err != nil {
    log.Fatal(err)
}

data := map[string]any{
    "user": map[string]any{"profile": map[string]any{"name": "a", "age": 200}},
}

if err := s.Validate(data); err != nil {
    var ve *schematics.ValidationErrors
    if errors.As(err, &ve) {
        for _, msg := range ve.Strings("en", "%target: %message") {
            fmt.Println(msg)
        }
    }
}

Validate returns:

  • nil when the data is valid,
  • a *ValidationErrors when the data breaks the rules,
  • a *SchemaError when the schema references an unknown rule/operator/condition.

Use errors.As to tell them apart.

Working with raw bytes

ImportSchema and ValidateBytes skip the manual json.Unmarshal step when you already have schema and data as []byte — a config file, a request body, an embedded fixture:

s, err := schematics.ImportSchema(schemaBytes) // New + LoadBytes in one call
if err != nil {
    log.Fatal(err)
}

// isArray tells ValidateBytes whether dataBytes is a single JSON object or
// an array of objects — pass it explicitly instead of relying on shape-
// sniffing, so a mismatched payload fails with a clear parse error.
if err := s.ValidateBytes(dataBytes, false /* isArray */); err != nil {
    // *ValidationErrors, *SchemaError, or a JSON parse error
}

ValidateBytesCtx takes a context.Context the same way ValidateCtx does.

The schema

{
  "version": "2.0",
  "separator": ".",
  "arrayIdKey": "id",
  "locale": "en",
  "db": { "minAge": 18 },
  "fields": [
    {
      "target": "user.profile.name",
      "type": "string",
      "required": true,
      "dependsOn": ["user.profile.email"],
      "addToDB": false,
      "when":     [ { "condition": "fieldPresent", "args": { "field": "user.profile.email" } } ],
      "validate": [ { "rule": "minLength", "args": { "min": 2 }, "message": "too short", "messages": { "ar": "قصير جدا" } } ],
      "operate":  [ { "op": "trim" }, { "op": "capitalize" } ]
    }
  ]
}
Field keys
Key Meaning
target Flattened key to match. Literal, * wildcard (one segment), or a regex when targetRegex is true.
targetRegex Treat target as a Go regular expression.
required Fail if the target selects no value.
dependsOn Other targets that must be present for this field to be validated.
addToDB Copy the matched value into the shared db before validation.
when Conditions that must all hold for the field to run. A flat list is ANDed; entries can nest any/all/not groups — see Boolean logic in when.
validate Ordered validators. The first failing rule reports one error and stops that field.
operate Ordered operators, applied by Operate.
name, displayName, type, description, tags, meta Metadata; carried through untouched.
Targeting arrays

Flattening turns {"tags":["a","b"]} into tags.0, tags.1. To validate the array itself (length, uniqueness), target the parent key — the engine reconstructs the array for you:

{ "target": "tags", "validate": [ { "rule": "minItems", "args": { "min": 1 } }, { "rule": "unique" } ] }

To validate each element, use a wildcard: "target": "tags.*".

Validating an array of objects? Pass a []map[string]any (or JSON array) to Validate; set arrayIdKey so each error carries a RowID.

Built-in validators

Strings: isString, notEmpty, email, maxLength, minLength, lengthBetween, exactLength, noSpecialChars, hasSpecialChars, hasUpper, hasLower, hasDigit, alpha, alphanumeric, contains, startsWith, endsWith, isURL, notURL, urlHasHost, urlHasQuery, isHTTPS, isUUID, isIP, isCIDR, isJSON, isBase64, isHex, equals, inOptions, notInOptions, matchRegex, like.

Numbers: isNumber, isInteger, isFloat, max, min, between, positive, negative, nonNegative, multipleOf.

Types: isBoolean, isObject (also used by WithTypeChecks).

Dates: isDate, beforeNow, afterNow, before, after, betweenTime.

Arrays: isArray, maxItems, minItems, unique, itemsInOptions.

Every validator inspects its input safely — the wrong type returns an error, it never panics.

Built-in operators

trim, capitalize, upper, lower, toString, add, subtract, multiply, divide, round, default, arrayToObject, replace, slugify, truncate, split, join, coerce.

out, err := s.Operate(data) // returns the transformed document

Built-in conditions

fieldPresent, fieldAbsent, fieldEquals, fieldMatches (regex), fieldGreaterThan, fieldLessThan, fieldIn — used in a field's when list. Set "negate": true on a condition to invert it.

Boolean logic in when

A flat when list is ANDed together — every entry must hold. Each entry can also be an any (OR), all (AND), or not group that composes other entries, including further nested groups:

{
  "target": "companyName",
  "required": true,
  "when": [
    { "any": [
      { "condition": "fieldEquals", "args": { "field": "accountType", "value": "business" } },
      { "condition": "fieldEquals", "args": { "field": "accountType", "value": "enterprise" } }
    ] }
  ]
}
{
  "target": "betaFeatureFlag",
  "when": [
    { "all": [
      { "condition": "fieldPresent", "args": { "field": "betaOptIn" } },
      { "not": { "condition": "fieldEquals", "args": { "field": "plan", "value": "legacy" } } }
    ] }
  ]
}

negate: true still works, on either a leaf or a group — {"not": {...}} and {"condition": "...", "negate": true} mean the same thing at whichever level you write it. An empty any: [] is vacuously false (nothing to satisfy); an empty all: [] is vacuously true, matching how a field with no when at all always runs. A plain flat list of leaves behaves exactly as it always has — this is purely additive, existing schemas are unaffected. Check validates condition names inside nested groups the same way it does for a flat list.

Custom rules, operators, conditions

Signatures are typed and context-aware. Register your own before validating:

s.RegisterRule("isAsh", func(v any, args schematics.Args, ctx *schematics.Context) error {
    if v == "ash" {
        return nil
    }
    return errors.New("must be ash")
})

s.RegisterOperator("reverse", func(v any, args schematics.Args, ctx *schematics.Context) (any, error) {
    str, _ := v.(string)
    r := []rune(str)
    for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r), nil
})

Args provides panic-free typed accessors: args.String("k"), args.Int("k"), args.Float("k"), args.Bool("k"), args.Strings("k"), plus args.StringOr / args.FloatOr. Context exposes the shared DB, Locale, Separator, the full flattened document Flat, the current RowID, a read-only Field view, and ctx.Ctx (a context.Context) for cancellation.

Configuration

s := schematics.New(
    schematics.WithSeparator("."),
    schematics.WithLocale("en"),
    schematics.WithArrayIDKey("id"),
    schematics.WithDB(map[string]any{"minAge": 18}),
    schematics.WithLogger(slog.Default()),
    schematics.WithTypeChecks(),          // enforce each field's "type"
    schematics.WithCollectAll(),          // report every failing rule per field
    schematics.WithMaxBodyBytes(1 << 20), // cap API request bodies at 1 MiB
    schematics.WithObserver(observer),    // per-rule / per-validation events
    schematics.WithTracer(tracer),        // wrap validation in a span
)

Options take precedence over values set inside the schema file.

Building schemas in Go

Prefer defining a schema in code over hand-writing JSON? The fluent builder gives you editor autocomplete and compile-time checking of rule and field names, then emits an ordinary Schema:

s, err := schematics.NewSchema().
    Field("email").Required().Type("string").Email().
    Field("age").Type("integer").Min(18).Max(120).
    Field("name").MinLength(2).Trim().Capitalize().
    New(schematics.WithTypeChecks())
if err != nil {
    log.Fatal(err) // New runs Check, so an unknown rule fails here
}

Every built-in has a typed helper (Email(), MinLength(n), IsIP(), …), and Rule(name, args) / Op(name, args) / When(cond, args) are escape hatches for custom or less common ones. .Schema() returns the Schema value if you'd rather apply it yourself.

Enforcing types and collecting every error

By default a field's type is documentation. Turn on WithTypeChecks() and each field's type (string, number, integer, boolean, array, date, object) is enforced before its own validators run. Turn on WithCollectAll() and each field reports every failing rule instead of stopping at the first — ideal for form validation:

s := schematics.New(schematics.WithCollectAll())
_ = s.LoadBytes([]byte(`{"fields":[{"target":"pw","validate":[
    {"rule":"minLength","args":{"min":8}},{"rule":"hasUpper"},{"rule":"hasDigit"}]}]}`))
// "ab" -> three errors, not one

Concurrency

A *Schematics and an *API are safe for concurrent use once the schema is loaded and any custom rules are registered. Build one at start-up and share it across request-handling goroutines — validation takes only read locks and reuses a cached, compiled matcher for every wildcard/regex target. Registering rules or loading a new schema must happen during setup, before the value is shared.

Key/separator note: flattening joins keys with the separator (. by default). If your documents can contain object keys that include the separator, pick one that can't occur in your keys via WithSeparator — otherwise the flatten/deflate round-trip is ambiguous for those keys.

Error handling

var ve *schematics.ValidationErrors
if errors.As(err, &ve) {
    ve.Strings("en", "%target: %message") // []string, formatted
    ve.Messages("ar")                      // []string, localized messages only
    ve.ForTarget("user.profile.name")      // []*ValidationError
    for _, e := range ve.Errors {
        _ = e.Target    // "user.profile.name"
        _ = e.Pointer() // "/user/profile/name"
        _ = e.Rule      // "minLength"
        _ = e.Code      // "rule.minLength"
        _ = e.Value     // the offending value
        _ = e.RowID     // array row id, if any
        _ = e.Message("ar")
    }
}

Format tokens: %message, %target, %pointer, %rule (alias %validator), %code, %value, %id. Each *ValidationError also marshals to a stable JSON object.

Error codes

Messages are for people and change freely — reworded, translated, replaced by a catalog. Codes are for machines and do not:

switch e.Code {
case schematics.CodeRequired:        // "field.required"
case schematics.RuleCode("email"):   // "rule.email"
}

Schema problems are coded too, so a test can assert why a schema was rejected without matching on message text:

var se *schematics.SchemaError
if errors.As(s.Check(), &se) {
    se.Codes()   // []Code{"schema.unknownRule"}
    se.Details   // []SchemaProblem{{Code, Target, Message}}
}

The full catalog, and the stability guarantee that comes with it, is in CODES.md.

Structured errors: JSON Pointer + problem+json

Every failure knows where it happened as an RFC 6901 JSON Pointer, relative to the document you passed in:

e.Target    // "user.profile.email"  — the schema's flattened key
e.Pointer() // "/user/profile/email" — a location in the JSON document

For an array, the pointer addresses the row by index while RowID keeps whatever arrayIdKey identified it as — the two answer different questions:

e.RowID     // "ORD-2"
e.Pointer() // "/1/email"

Failures that are not about a location in the data — an unmatched route, an oversized body — return "", the pointer to the whole document.

RFC 7807 responses

WriteProblem turns any error from Validate, ValidateRequest, or ValidateStream into an application/problem+json response:

func handler(w http.ResponseWriter, r *http.Request) {
    if err := api.ValidateRequest(r); err != nil {
        _ = api.WriteProblem(w, r, err) // sets status + Content-Type
        return
    }
    // ...
}
{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "the request payload failed 1 validation rule",
  "instance": "/orders",
  "errors": [
    {
      "pointer": "/customer/email",
      "target": "customer.email",
      "code": "rule.email",
      "rule": "email",
      "detail": "\"nope\" is not a valid email address"
    }
  ]
}

The status is derived from what actually went wrong — 422 for a payload that failed its rules, 404 for an unmatched route, 413 for a body over the cap, 500 for an unsound schema — because that is a property of the failure, not of the handler. Override it and everything else with options:

schematics.WriteProblem(w, r, err,
    schematics.WithProblemType("https://example.com/probs/validation"),
    schematics.WithProblemTitle("Validation failed"),
    schematics.WithProblemStatus(http.StatusBadRequest),
    schematics.WithProblemLocale("fr"),
)

api.WriteProblem (the method) defaults the locale to the engine's own, so a handler doesn't repeat configuration it already gave NewAPI. NewProblem returns the *Problem without writing it, for callers who want to embed it in a larger response.

Observability

One optional hook underlies all of this. With no observer attached the engine never reads the clock and never builds an event — the cost is a nil check.

s := schematics.New(schematics.WithObserver(
    schematics.ObserverFunc(func(e schematics.Event) {
        switch e.Kind {
        case schematics.EventRule:       // one per rule evaluated
            if e.Failed {
                log.Printf("%s rejected %s (%s)", e.Rule, e.Target, e.Code)
            }
        case schematics.EventValidation: // one per Validate call, or per streamed row
            log.Printf("%s: %d rows, %d errors in %s", e.Op, e.Rows, e.Errors, e.Duration)
        }
    })))

Event.Op is validate, validate_array, validate_stream, or validate_request. A request is reported as one validation covering its headers, query and body rather than three, and a stream reports one validation per row plus one span for the whole stream.

Rule events fire whether the rule passed or failed, including the field-level required and dependsOn checks — a failure rate needs a denominator, and one that only ever counted failures would read 100% forever. A field that was never evaluated at all (optional, absent, dependencies unmet) reports nothing.

Timings live on validation events, not rule events, on purpose: reading the clock twice per rule would cost more than most rules do, and would be charged to every caller whether they wanted timings or not.

Observe is called synchronously on the validating goroutine, and a panic in it is not swallowed — a broken observer should fail loudly rather than quietly corrupt the numbers it reports. On the streaming path, where rows are validated on goroutines this package owns, the panic is captured and re-raised on your goroutine as a *StreamPanic carrying the original value and the stack from where it happened, so your recover behaves the same either way.

slog
s := schematics.New(schematics.WithObserver(
    schematics.NewSlogObserver(slog.Default(), slog.LevelDebug)))

Failing rules and validation summaries are logged; passing rules are not — a line per passing rule is noise at any realistic volume, and the count belongs in a metric.

Prometheus

The adapter takes functions rather than interfaces, so nothing here imports a metrics library: Prometheus vectors return their own concrete types, which no locally-declared interface can accept without a wrapper per vector anyway.

validations := prometheus.NewCounterVec(prometheus.CounterOpts{
    Name: "schematics_validations_total"}, []string{"op", "outcome"})
seconds := prometheus.NewHistogramVec(prometheus.HistogramOpts{
    Name: "schematics_validation_seconds"}, []string{"op"})
failures := prometheus.NewCounterVec(prometheus.CounterOpts{
    Name: "schematics_rule_failures_total"}, []string{"rule", "code"})
reg.MustRegister(validations, seconds, failures)

m := schematics.Metrics{
    Validations: func(op, outcome string, d time.Duration) {
        validations.WithLabelValues(op, outcome).Inc()
        seconds.WithLabelValues(op).Observe(d.Seconds())
    },
    RuleFailures: func(rule, code, _ string) {
        failures.WithLabelValues(rule, code).Inc()
    },
}
s := schematics.New(schematics.WithObserver(m.Observer()))

Cardinality. rule and code are bounded by your schema and are safe as labels. target is not: a wildcard target like items.*.sku expands to items.0.sku, items.1.sku, … — one new time series per array index your system ever sees. It is passed to the callbacks because some schemas are entirely literal, but dropping it (as above) is the safe default.

OpenTelemetry

Same reasoning: the engine talks to two small interfaces and you supply a twenty-line adapter, so the SDK dependency lives in your module rather than in everyone's.

type otelTracer struct{ t trace.Tracer }

func (o otelTracer) Start(ctx context.Context, name string) (context.Context, schematics.Span) {
    ctx, span := o.t.Start(ctx, name)
    return ctx, otelSpan{span}
}

type otelSpan struct{ s trace.Span }

func (o otelSpan) End() { o.s.End() }
func (o otelSpan) SetAttributes(attrs ...schematics.Attribute) {
    kvs := make([]attribute.KeyValue, 0, len(attrs))
    for _, a := range attrs {
        switch v := a.Value.(type) {
        case string:
            kvs = append(kvs, attribute.String(a.Key, v))
        case int:
            kvs = append(kvs, attribute.Int(a.Key, v))
        case bool:
            kvs = append(kvs, attribute.Bool(a.Key, v))
        }
    }
    o.s.SetAttributes(kvs...)
}

s := schematics.New(schematics.WithTracer(otelTracer{otel.Tracer("myapp")}))

Spans are named schematics.validate, schematics.validate_array, and schematics.validate_stream, with attributes for the schema's field count, rows validated, errors found, and a schematics.valid boolean. The span's context is threaded into every rule via Context.Ctx, so a rule that calls a database can open a child span that nests correctly.

ValidateRequest starts no span of its own — your HTTP middleware already has one, and the sections validate inside it.

Message catalogs (i18n)

Hand-writing message/messages on every RuleRef in every schema doesn't scale past a couple of locales. A Catalog loads external message bundles keyed by locale then rule name, so translators can maintain one file per locale instead:

{
  "en": { "required": "{target} is required.", "minLength": "{target} must be at least {min} characters" },
  "ar": { "required": "{target} مطلوب." }
}
cat, err := schematics.LoadCatalogFile("messages.json") // or LoadCatalogTOMLFile
if err != nil {
    log.Fatal(err)
}
s := schematics.New(schematics.WithCatalog(cat))

A [locale] / rule = "template" TOML file works the same way (LoadCatalogTOMLFile / LoadCatalogTOMLBytes) — a small hand-rolled subset (no arrays, tables-of-tables, or multiline strings), kept dependency-free on purpose.

Templates support {name} interpolation — filled from the rule's own args (so minLength's template can use {min}) plus {target}, {value}, and {id} — and a practical subset of ICU MessageFormat pluralization, not the full spec:

{ "minItems": "{min, plural, one {at least # item} other {at least # items}} required" }

# is replaced with the formatted number; branches are matched by an exact =N selector first, then one (when the number is 1) or other. There's no select/selectordinal, no nested plural-inside-plural, and no CLDR-aware plural categories beyond one/other.

Resolution order for ValidationError.Message(locale): an explicit per-rule schema message for that locale always wins first; then, if a Catalog is attached, its rendered template; then the rule's own generic message; then a generated description. So the catalog only fills gaps — a schema's own message/messages still overrides it, and a rule with no catalog entry at all falls back exactly as it did before WithCatalog existed.

Locale fallback chain: requesting "ar-EG" tries "ar-EG", then "ar" (stripping BCP-47-style subtags), then the catalog's DefaultLocale (or "en" if unset), before giving up.

Catching target typos before they ship

Because a schema is data, not code, a typo in a field's target doesn't fail to compile — it just quietly matches nothing at runtime. Check() (run automatically before every Validate) catches a typo in a JSON key, like "tagret" instead of "target": encoding/json drops the unrecognized key, Target ends up "", and Check reports "field #N has an empty target".

A typo in the target's value"target": "mane" instead of "name" — is a perfectly valid string, so Check alone has nothing to flag. ValidateSchema closes that gap by matching every target against a sample of your real data:

s := schematics.New()
s.LoadFile("schema.json")

sample := map[string]any{"name": "Ada", "email": "ada@example.com"}
if err := s.ValidateSchema(sample); err != nil {
    log.Fatal(err) // *SchemaError: field #0: target "mane" does not match anything in the sample data
}

Run it once in a test alongside a fixture (or a golden payload) so schema/data drift fails CI instead of failing silently in production:

func TestSchemaMatchesFixture(t *testing.T) {
    s := schematics.New()
    if err := s.LoadFile("schema.json"); err != nil {
        t.Fatal(err)
    }
    if err := s.ValidateSchema(loadFixture(t)); err != nil {
        t.Fatal(err) // schema drifted from the real payload shape — fix the typo
    }
}

If a field is legitimately optional and absent from every fixture you have, pass its target to ignoreTargets so it isn't flagged:

s.ValidateSchema(sample, "user.profile.middleName")

ValidateSchema runs every check Check does, so it also still catches unknown rule/operator/condition names and duplicate targets.

HTTP request validation

Validate *http.Request headers, query, and body against per-endpoint field sets:

api := schematics.NewAPI()
if err := api.LoadFile("api.schema.json"); err != nil {
    log.Fatal(err)
}
// api.Base().RegisterRule(...) to add custom rules used by the request schema
if err := api.ValidateRequest(r); err != nil {
    // *ValidationErrors or *SchemaError
}

Endpoint paths support :name (one segment) and a trailing * wildcard. See examples/api.schema.json.

CLI

cmd/schematics wraps Check, Validate, and ValidateSchema so schemas and data can be checked in CI, a Makefile, or a pre-commit hook without writing any Go:

go install github.com/ashbeelghouri/json-schematics-v2/cmd/schematics@latest
# validate data against a schema; --array for a JSON array of objects
schematics validate --schema examples/person.schema.json examples/person.data.json

# make sure a schema only references known rules/operators/conditions
schematics check examples/person.schema.json

# catch a target that typo'd or drifted from real data
schematics lint --sample examples/person.data.json examples/person.schema.json

Every subcommand accepts --json for machine-readable output and uses exit codes a pipeline can branch on: 0 valid, 1 problems found, 2 misuse (bad flags, missing file, unparsable JSON). A minimal GitHub Action step:

- run: go install github.com/ashbeelghouri/json-schematics-v2/cmd/schematics@latest
- run: schematics validate --schema api.schema.json --json testdata/request.json

Or a Makefile target:

lint-schemas:
	schematics check api.schema.json
	schematics lint --sample testdata/sample.json api.schema.json

Performance

Validation is on the hot path of every request in most services that use this library, so it is written to keep allocations down rather than to be clever. Measured on the repository's own benchmarks (go test -bench=. -benchmem, 6 runs, linux/amd64):

Benchmark ns/op B/op allocs/op
BenchmarkValidateObject 10700 → 5362 (−50%) 4490 → 2823 (−37%) 86 → 33 (−62%)
BenchmarkValidateArray (100 rows) 1023005 → 550004 (−46%) 430919 → 281003 (−35%) 7924 → 3203 (−60%)
BenchmarkFlatten 1188 → 733 (−38%) 384 → 440 19 → 8 (−58%)

Three things do most of that work:

  • Documents that are already JSON-native are not re-marshalled. Validate normally round-trips its input through encoding/json so that every rule sees the same value types it would if the document had arrived over the wire. When the input already consists of JSON-native values — which is the case whenever it came from json.Unmarshal, including the ValidateBytes and HTTP paths — that round-trip is the identity function and is skipped. Anything the library cannot prove equivalent (float32, NaN/Inf, []byte, structs, invalid UTF-8, json.Number, ...) still takes the round-trip, so results never differ. A dedicated fuzz target, FuzzNormalizeEquivalence, asserts this.
  • Literal targets skip the regexp. A target with no * and no targetRegex is resolved with a single map lookup rather than by matching a compiled pattern against every flattened key.
  • Per-object working state is pooled. The field views, rule contexts and DB map are reused between fields and between rows of an array.
Two things to know

Validate never modifies your data, but it may read it without copying it first. Do not mutate a document concurrently with a Validate call on it.

A *Context, its *FieldView, and the DB map it carries are valid only for the duration of the rule call they are passed to, because they are pooled and reused. A custom rule that needs to keep any of them must copy what it needs rather than retaining the pointer:

s.RegisterRule("remember", func(v any, args schematics.Args, c *schematics.Context) error {
    seen = append(seen, c.RowID)       // fine: string is copied
    // seen = append(seen, c)          // NOT fine: pointer into a pooled buffer
    return nil
})

Reading c.Flat and c.DB inside the call is fine; holding on to them is not.

Streaming very large arrays

Validate needs the whole array in memory. For an export that does not fit — or that arrives over a network and has no reason to be buffered — validate it as it decodes:

err := s.ValidateStream(resp.Body)

ValidateStream reads a JSON array of objects one row at a time and returns the same *ValidationErrors, with the same errors in the same order, that Validate would have returned for the same data. Peak memory is set by how many rows are in flight, not by how many rows there are: validating 50,000 rows holds about as much live data as validating 5,000.

To act on failures as they arrive rather than collecting them all, use ValidateStreamEach:

var bad int
err := s.ValidateStreamEach(ctx, r, func(res schematics.RowResult) error {
    if res.Valid() {
        return nil
    }
    if bad++; bad > 100 {
        return fmt.Errorf("too many invalid rows, stopping at %d", res.Index)
    }
    log.Printf("row %s: %v", res.RowID, res.Errors)
    return nil
})

fn is called once per row, in row order, from a single goroutine — so it does not need to be safe for concurrent use. Returning an error from it stops the walk and that error comes back to the caller, which is how you implement a failure budget. RowResult.RowID follows the same rule as the in-memory array path: the row's arrayIdKey value when it has one, its index otherwise.

Panics

Streaming validates rows on goroutines this package owns, and a panic escaping one of those would kill the process outright — your recover cannot reach a goroutine you did not start. So a panic from a custom rule, condition, or observer is captured and re-raised on your goroutine:

defer func() {
    if r := recover(); r != nil {
        var sp *schematics.StreamPanic
        if errors.As(r.(error), &sp) {
            log.Printf("rule panicked: %v\n%s", sp.Value, sp.Stack)
        }
    }
}()
err := s.ValidateStream(body)

StreamPanic.Value is the original panic value and Unwrap returns it when it is an error, so errors.Is/errors.As still reach it. The wrapper exists to carry Stack — by the time the value reaches your goroutine the stack it came from is gone, and that stack is the part worth reading — and Suppressed, the error the call would otherwise have returned. A panic outranks an error, since only one of the two can be answered by retrying with better input, but the error is still real: a truncated stream that also panicked shouldn't look like a clean one that panicked.

Concurrency

WithConcurrency(n) validates up to n rows in parallel, and is also the bound on how many rows are held at once. It changes throughput, never results — errors still come out in row order, and the output is identical at every setting.

err := s.ValidateStream(r, schematics.WithConcurrency(8))

It is worth raising when your rules are expensive — one that calls a database, say. For ordinary CPU-bound rules the default of 1 is usually right.

What it costs

Streaming is not the faster option; it is the bounded one. Validating 2,000 rows from bytes:

ms/op peak live memory
ValidateBytes (whole array) 7.4 grows with the array
ValidateStream 10.4 flat
ValidateStream + WithConcurrency(4) 8.6 flat

Roughly 40% more wall time buys memory that does not depend on input size, and some of that is recovered by concurrency. Reach for Validate or ValidateBytes when the data comfortably fits, and for ValidateStream when it does not or when you would rather not find out.

Examples

Runnable schema and data live in examples/. The package-level Example in the tests loads them end to end. Try any schema live in the playground.

Development

go test ./...
go test -race -cover ./...
go vet ./...
go test -bench=. -benchmem ./...
go test -run=^$ -fuzz=FuzzValidate -fuzztime=30s ./...

Changes go in CHANGELOG.md. Two things there are contracts rather than documentation, and both are enforced by tests that fail the build:

  • The serialized error shape. The JSON a ValidationError marshals to, and the RFC 7807 problem body, are public API. Keys may be added in a minor release — consumers are expected to ignore unknown ones — but the addition goes under a "Wire format" heading in the changelog, and wire_format_test.go pins the key sets so it cannot happen quietly. Removing or repurposing a key needs a major.
  • Error codes. See CODES.md for the catalog and the add-don't-rename path for a code that turns out wrong.

License

MIT — see LICENSE.

Documentation

Overview

Package schematics validates and transforms arbitrary JSON documents against a declarative, data-driven schema.

The model is simple: a document is flattened into dotted keys (for example user.profile.name or tags.0), each schema field targets one or more of those keys (literally, with a * wildcard, or with a full regular expression), and every matched value is run through a chain of validators and operators. Validators report problems as typed ValidationError values; operators transform the value in place.

The package has no third-party dependencies.

Example

Example loads the bundled person schema and prints the validation errors for an invalid document. Errors are reported in schema field order.

package main

import (
	"errors"
	"fmt"

	schematics "github.com/ashbeelghouri/json-schematics-v2"
)

func main() {
	s := schematics.New()
	if err := s.LoadFile("examples/person.schema.json"); err != nil {
		panic(err)
	}

	data := map[string]any{
		"user": map[string]any{
			"profile": map[string]any{"name": "a", "age": 200},
			"tags":    []any{"x", "x"},
		},
	}

	if err := s.Validate(data); err != nil {
		var ve *schematics.ValidationErrors
		if errors.As(err, &ve) {
			for _, msg := range ve.Strings("en", "%target: %message") {
				fmt.Println(msg)
			}
		}
	}
}
Output:
user.profile.name: name is too short
user.profile.age: age must be 0-120
user.tags: items must be unique (duplicate x)

Index

Examples

Constants

View Source
const ProblemContentType = "application/problem+json"

ProblemContentType is the media type RFC 7807 requires on a problem details response.

View Source
const Version = "2.0"

Version is the schema/library generation this package implements.

Variables

This section is empty.

Functions

func Deflate

func Deflate(flat map[string]any, separator string) map[string]any

Deflate reconstructs a nested structure from a flattened map. Segments that are consecutive integers starting at zero are rebuilt as slices.

func Flatten

func Flatten(data map[string]any, separator string) map[string]any

Flatten converts a nested map into a single-level map whose keys are the paths to each leaf value, joined by separator. Arrays become indexed keys, so {"tags":["a","b"]} becomes {"tags.0":"a","tags.1":"b"}. Empty maps and slices are preserved as leaf values so length-style validators can still see them.

Flatten/Deflate round-trip losslessly only when object keys do not contain the separator and are non-empty. A literal key like "a.b" is indistinguishable from nested {"a":{"b":...}} once flattened, so if your documents can carry keys containing "." (the default separator), choose a separator that cannot occur in your keys via WithSeparator.

func RuleName added in v1.0.3

func RuleName(c Code) (string, bool)

RuleName returns the validator name embedded in a rule code and reports whether c was in fact a rule code. It is the inverse of RuleCode.

func WriteProblem added in v1.0.3

func WriteProblem(w http.ResponseWriter, r *http.Request, err error, opts ...ProblemOption) error

WriteProblem renders err as an RFC 7807 response. It is the one-line form of NewProblem followed by Write, and is a no-op when err is nil.

if err := api.ValidateRequest(r); err != nil {
    _ = schematics.WriteProblem(w, r, err)
    return
}

r may be nil; when it is not, its path is used as the problem's "instance".

Example

ExampleWriteProblem returns validation failures as an RFC 7807 application/problem+json response — the shape most HTTP clients already know how to display.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	schematics "github.com/ashbeelghouri/json-schematics-v2"
)

const orderSchema = `{"fields":[
	{"target":"customer.email","required":true,"validate":[{"rule":"email"}]},
	{"target":"quantity","validate":[{"rule":"min","args":{"min":1}}]}
]}`

func main() {
	s, err := schematics.ImportSchema([]byte(orderSchema))
	if err != nil {
		panic(err)
	}

	handler := func(w http.ResponseWriter, r *http.Request) {
		if err := s.ValidateBytes([]byte(`{"customer":{"email":"nope"},"quantity":2}`), false); err != nil {
			_ = schematics.WriteProblem(w, r, err,
				schematics.WithProblemType("https://example.com/probs/validation"))
			return
		}
		w.WriteHeader(http.StatusNoContent)
	}

	rec := httptest.NewRecorder()
	handler(rec, httptest.NewRequest("POST", "/orders", nil))

	fmt.Println(rec.Code)
	fmt.Println(rec.Header().Get("Content-Type"))
	fmt.Println(rec.Body.String())
}
Output:
422
application/problem+json
{"type":"https://example.com/probs/validation","title":"Unprocessable Entity","status":422,"detail":"the request payload failed 1 validation rule","instance":"/orders","errors":[{"pointer":"/customer/email","target":"customer.email","code":"rule.email","rule":"email","detail":"\"nope\" is not a valid email address"}]}

Types

type API

type API struct {
	// contains filtered or unexported fields
}

API validates *http.Request values against an APISchema. It reuses a base Schematics for its registries and options, so custom rules registered there are available to request validation too.

func NewAPI

func NewAPI(opts ...Option) *API

NewAPI creates an API validator with the given options (shared with the underlying engine).

func (*API) Base

func (a *API) Base() *Schematics

Base exposes the underlying Schematics so callers can register custom rules, operators, and conditions used by the request schema.

func (*API) LoadBytes

func (a *API) LoadBytes(b []byte) error

LoadBytes parses an API schema from JSON.

func (*API) LoadFile

func (a *API) LoadFile(path string) error

LoadFile reads and parses an API schema file.

func (*API) ValidateRequest

func (a *API) ValidateRequest(r *http.Request) error

ValidateRequest validates r against the endpoint matching its method and path. It returns nil, a *ValidationErrors, or a *SchemaError. If no endpoint matches, it returns a *ValidationErrors describing the mismatch.

func (*API) WriteProblem added in v1.0.3

func (a *API) WriteProblem(w http.ResponseWriter, r *http.Request, err error, opts ...ProblemOption) error

WriteProblem is WriteProblem bound to this API, so failure messages are rendered in the locale the engine was configured with rather than the library default. An explicit WithProblemLocale still wins.

type APIEndpoint

type APIEndpoint struct {
	Path    string  `json:"path"`
	Method  string  `json:"method"`
	Headers []Field `json:"headers,omitempty"`
	Query   []Field `json:"query,omitempty"`
	Body    []Field `json:"body,omitempty"`
}

APIEndpoint validates one method+path combination.

type APIGlobal

type APIGlobal struct {
	Headers []Field `json:"headers,omitempty"`
}

APIGlobal holds fields applied to every endpoint (currently headers).

type APISchema

type APISchema struct {
	Version   string        `json:"version,omitempty"`
	Separator string        `json:"separator,omitempty"`
	Global    APIGlobal     `json:"global,omitempty"`
	Endpoints []APIEndpoint `json:"endpoints"`
}

APISchema describes request validation for a set of HTTP endpoints. Each endpoint validates its headers, query parameters, and JSON body using the same Field model as the core schema.

Example (JSON):

{
  "version": "2.0",
  "global": { "headers": [ { "target": "authorization", "required": true } ] },
  "endpoints": [
    {
      "path": "/users/:id",
      "method": "POST",
      "query":  [ { "target": "verbose", "validate": [ { "rule": "inOptions", "args": { "options": ["true","false"] } } ] } ],
      "body":   [ { "target": "email", "required": true, "validate": [ { "rule": "email" } ] } ]
    }
  ]
}

type Args

type Args map[string]any

Args holds the arguments a schema passes to a validator, operator, or condition. The typed accessors never panic: a missing key or a wrong type is reported as an error instead, which is what makes built-in and custom rules safe to run against arbitrary data.

func (Args) Bool

func (a Args) Bool(key string) (bool, error)

Bool returns the boolean argument at key.

func (Args) Float

func (a Args) Float(key string) (float64, error)

Float returns the numeric argument at key as a float64.

func (Args) FloatOr

func (a Args) FloatOr(key string, def float64) float64

FloatOr returns the numeric argument at key, or def if absent or mistyped.

func (Args) Get

func (a Args) Get(key string) (any, bool)

Get returns the raw argument at key.

func (Args) Has

func (a Args) Has(key string) bool

Has reports whether key is present.

func (Args) Int

func (a Args) Int(key string) (int, error)

Int returns the numeric argument at key truncated to an int.

func (Args) String

func (a Args) String(key string) (string, error)

String returns the string argument at key.

func (Args) StringOr

func (a Args) StringOr(key, def string) string

StringOr returns the string argument at key, or def if absent or mistyped.

func (Args) Strings

func (a Args) Strings(key string) ([]string, error)

Strings returns the argument at key as a slice of strings.

type Attribute added in v1.0.3

type Attribute struct {
	Key   string
	Value any
}

Attribute is one key/value pair recorded on a span. Value is one of string, int, int64, float64, or bool — the types every tracing backend can represent natively.

type Catalog added in v1.0.3

type Catalog struct {
	// DefaultLocale is tried after the requested locale's own fallback chain
	// is exhausted (see localeChain). Defaults to "en" when empty.
	DefaultLocale string
	// contains filtered or unexported fields
}

Catalog holds external, file-loaded message templates keyed by locale then by rule name. It exists so localization can scale past hand-writing "message"/"messages" on every RuleRef in a schema: load one catalog file (JSON or a small TOML subset) instead, and attach it with WithCatalog.

ValidationError.Message consults a catalog only when the failing rule has no inline "message"/"messages" of its own for the requested locale — an explicit per-rule schema message always wins over the catalog.

func LoadCatalogBytes added in v1.0.3

func LoadCatalogBytes(b []byte) (*Catalog, error)

LoadCatalogBytes parses a JSON catalog of the shape {"locale": {"rule": "template", ...}, ...}, e.g.:

{
  "en": { "required": "{target} is required.", "minLength": "must be at least {min} characters" },
  "ar": { "required": "{target} مطلوب." }
}

func LoadCatalogFile added in v1.0.3

func LoadCatalogFile(path string) (*Catalog, error)

LoadCatalogFile reads and parses a JSON catalog file.

func LoadCatalogTOMLBytes added in v1.0.3

func LoadCatalogTOMLBytes(b []byte) (*Catalog, error)

LoadCatalogTOMLBytes parses a minimal TOML subset — one [locale] section per locale, each followed by `rule = "template"` lines:

[en]
required = "{target} is required."

[ar]
required = "{target} مطلوب."

This is NOT a general TOML parser: no arrays, no tables-of-tables, no multiline strings, no non-string values — just enough to let a catalog be hand-edited as TOML without pulling in a dependency (this library is zero-dependency by design). Blank lines and lines starting with '#' are ignored.

func LoadCatalogTOMLFile added in v1.0.3

func LoadCatalogTOMLFile(path string) (*Catalog, error)

LoadCatalogTOMLFile reads and parses a TOML catalog file. See LoadCatalogTOMLBytes for the supported subset.

func NewCatalog added in v1.0.3

func NewCatalog() *Catalog

NewCatalog returns an empty catalog. Add entries with Merge, or start from a file with LoadCatalogFile / LoadCatalogTOMLFile.

func (*Catalog) Merge added in v1.0.3

func (c *Catalog) Merge(locale string, templates map[string]string)

Merge adds (or overwrites) the rule -> template entries for locale.

func (*Catalog) Render added in v1.0.3

func (c *Catalog) Render(locale, rule string, args map[string]any) (msg string, ok bool)

Render looks up a template for rule under locale — walking localeChain until one locale has an entry for rule — and renders it against args via renderTemplate. ok is false when no locale in the chain has a template for rule, so the caller can fall back predictably (to the schema's own message, or a generated description) rather than showing nothing.

type Code added in v1.0.3

type Code string

Code is a stable machine-readable identifier for a failure. Compare it against the exported constants, or against RuleCode(name) for a validator.

const (
	// CodeRequired: a field marked required matched nothing in the document.
	CodeRequired Code = "field.required"
	// CodeDependsOn: a field's dependsOn targets were not all present.
	CodeDependsOn Code = "field.dependsOn"
)

Data-level codes. These describe a document that did not satisfy the schema.

const (
	// CodeEmptyTarget: a field declares no target.
	CodeEmptyTarget Code = "schema.emptyTarget"
	// CodeDuplicateTarget: two fields declare the same target.
	CodeDuplicateTarget Code = "schema.duplicateTarget"
	// CodeUnknownRule: a field references a validator that is not registered.
	CodeUnknownRule Code = "schema.unknownRule"
	// CodeUnknownOperator: a field references an operator that is not registered.
	CodeUnknownOperator Code = "schema.unknownOperator"
	// CodeUnknownCondition: a when node references a condition that is not registered.
	CodeUnknownCondition Code = "schema.unknownCondition"
	// CodeMalformedWhen: a when node is neither a valid leaf nor a valid group.
	CodeMalformedWhen Code = "schema.malformedWhen"
	// CodeSchemaInvalid: the API layer could not run a section's schema at all.
	// It wraps a *SchemaError raised while validating one request section.
	CodeSchemaInvalid Code = "schema.invalid"
	// CodeSampleMismatch: ValidateSchema found a target that matches nothing in
	// the sample document.
	CodeSampleMismatch Code = "schema.sampleMismatch"
)

Schema-level codes. These describe a schema that cannot be executed, and are carried by SchemaProblem rather than by ValidationError.

const (
	// CodeRouteNotFound: no endpoint in the API schema matches the request's
	// method and path.
	CodeRouteNotFound Code = "request.routeNotFound"
	// CodeBodyTooLarge: the request body exceeded WithMaxBodyBytes.
	CodeBodyTooLarge Code = "request.bodyTooLarge"
)

Request-level codes. These describe an HTTP request the API layer could not validate as presented, as opposed to one whose contents were invalid.

func RuleCode added in v1.0.3

func RuleCode(rule string) Code

RuleCode returns the stable code for a failing validator: the rule's registered name under the "rule." namespace, so the built-in minLength reports "rule.minLength" and a custom rule registered as "isTenantID" reports "rule.isTenantID".

Because the code is derived from the registered name, renaming a custom rule renames its code. Treat a rule name as the public identifier it is.

func (Code) Namespace added in v1.0.3

func (c Code) Namespace() string

Namespace returns the part of the code before the first dot: "field", "rule", "schema", or "request". It lets a consumer group codes by origin without knowing the full catalog.

func (Code) String added in v1.0.3

func (c Code) String() string

String returns the code as a plain string.

type Condition

type Condition func(args Args, ctx *Context) bool

Condition decides whether a field's validators and operators should run.

type ConditionRef

type ConditionRef = WhenNode

ConditionRef is a deprecated alias for WhenNode, kept so existing Go code that builds Field.When by hand (schematics.ConditionRef{...}) keeps compiling unchanged now that When holds boolean-composition groups too. Prefer WhenNode in new code.

type Context

type Context struct {
	Ctx       context.Context
	DB        map[string]any
	Locale    string
	Separator string
	Flat      map[string]any
	RowID     string
	Field     *FieldView
	// contains filtered or unexported fields
}

Context is handed to every validator, operator, and condition. It carries the shared DB, the active locale and separator, the full flattened document, and metadata about the field currently being processed. Ctx is a standard context.Context so long-running custom rules can honor cancellation.

A *Context, its *FieldView, and the DB map it carries are valid only for the duration of the rule call they are passed to. Validation reuses these structures between fields and between rows of an array via a sync.Pool, so a rule that needs to keep any of them must copy what it needs rather than retaining the pointer. Flat is the caller's own flattened document and is not pooled, but treat it as read-only.

func (*Context) FieldPresent

func (c *Context) FieldPresent(target string) bool

FieldPresent reports whether target selects at least one value in the document under validation. It honors the active separator and wildcard rules.

func (*Context) Lookup

func (c *Context) Lookup(target string) (any, bool)

Lookup returns the first value selected by target, if any.

type Event added in v1.0.3

type Event struct {
	// Kind is the granularity of this event.
	Kind EventKind
	// Op is the entry point that produced it.
	Op Operation
	// Target is the concrete flattened key the rule ran against (rule events).
	Target string
	// Rule is the validator that ran (rule events).
	Rule string
	// Code is the stable failure code, set only when Failed is true.
	Code Code
	// RowID identifies the array row, for array and stream operations.
	RowID string
	// Failed reports the outcome: for a rule event, whether the rule rejected
	// the value; for a validation event, whether any rule did.
	Failed bool
	// Errors counts the failures collected (validation events).
	Errors int
	// Rows counts the rows validated: 1 for an object, the array length for an
	// array, 1 per row for a stream (validation events).
	Rows int
	// Duration is how long the validation took (validation events). It is never
	// set on rule events — see the note at the top of this file.
	Duration time.Duration
}

Event describes something the engine just did. Which fields are meaningful depends on Kind:

EventValidation: Op, Duration, Rows, Errors, Failed
EventRule:       Op, Target, Rule, Code, RowID, Failed

Fields that do not apply are left at their zero value.

type EventKind added in v1.0.3

type EventKind string

EventKind distinguishes the granularities the engine reports at. Callers should ignore kinds they do not recognize rather than treating them as an error, so new kinds can be added without breaking them.

const (
	// EventValidation is emitted once per completed validation: one Validate
	// call, or one row of a streamed array.
	EventValidation EventKind = "validation"
	// EventRule is emitted once per rule evaluated against one value, whether
	// it passed or failed. The field-level checks (required, dependsOn) report
	// this way too, so a failure rate computed from these events has an honest
	// denominator. A field that was never evaluated at all — optional, absent,
	// and with unmet dependencies — reports nothing.
	EventRule EventKind = "rule"
)

type Field

type Field struct {
	// Target selects flattened keys: a literal path, a path with a "*"
	// wildcard, or (when TargetRegex is true) a regular expression.
	Target      string `json:"target"`
	TargetRegex bool   `json:"targetRegex,omitempty"`

	Name        string `json:"name,omitempty"`
	DisplayName string `json:"displayName,omitempty"`
	Type        string `json:"type,omitempty"`
	Description string `json:"description,omitempty"`

	// Required fails when the target selects no value.
	Required bool `json:"required,omitempty"`
	// DependsOn lists other targets that must be present for this field to be
	// validated.
	DependsOn []string `json:"dependsOn,omitempty"`
	// AddToDB copies the matched value into the shared DB before validation, so
	// other rules can reference it.
	AddToDB bool `json:"addToDB,omitempty"`

	Tags []string `json:"tags,omitempty"`

	// When lists conditions that must all hold for the field to be processed.
	// Each entry is a WhenNode: a flat list is ANDed together (backward
	// compatible with the pre-boolean-logic behavior), and any entry may
	// itself be a nested any/all/not group.
	When []WhenNode `json:"when,omitempty"`
	// Validate is the ordered chain of validators. The first failing rule stops
	// evaluation of this field and produces one error.
	Validate []RuleRef `json:"validate,omitempty"`
	// Operate is the ordered chain of operators applied by Operate.
	Operate []OperatorRef `json:"operate,omitempty"`

	Meta map[string]any `json:"meta,omitempty"`
}

Field targets one or more flattened keys and describes how to validate and transform the matched values.

type FieldBuilder added in v1.0.2

type FieldBuilder struct {
	// contains filtered or unexported fields
}

FieldBuilder accumulates one field's settings, validators, operators, and conditions. Any terminator method (Field, Done, Schema, New) commits it.

func (*FieldBuilder) AddToDB added in v1.0.2

func (fb *FieldBuilder) AddToDB() *FieldBuilder

AddToDB copies the matched value into the shared DB before validation.

func (*FieldBuilder) After added in v1.0.2

func (fb *FieldBuilder) After(date string) *FieldBuilder

func (*FieldBuilder) AfterNow added in v1.0.2

func (fb *FieldBuilder) AfterNow() *FieldBuilder

func (*FieldBuilder) Alpha added in v1.0.2

func (fb *FieldBuilder) Alpha() *FieldBuilder

func (*FieldBuilder) Alphanumeric added in v1.0.2

func (fb *FieldBuilder) Alphanumeric() *FieldBuilder

func (*FieldBuilder) Before added in v1.0.2

func (fb *FieldBuilder) Before(date string) *FieldBuilder

func (*FieldBuilder) BeforeNow added in v1.0.2

func (fb *FieldBuilder) BeforeNow() *FieldBuilder

func (*FieldBuilder) Between added in v1.0.2

func (fb *FieldBuilder) Between(min, max float64) *FieldBuilder

func (*FieldBuilder) Capitalize added in v1.0.2

func (fb *FieldBuilder) Capitalize() *FieldBuilder

func (*FieldBuilder) Contains added in v1.0.2

func (fb *FieldBuilder) Contains(sub string) *FieldBuilder

func (*FieldBuilder) Default added in v1.0.2

func (fb *FieldBuilder) Default(v any) *FieldBuilder

func (*FieldBuilder) DependsOn added in v1.0.2

func (fb *FieldBuilder) DependsOn(targets ...string) *FieldBuilder

DependsOn adds required-present dependency targets.

func (*FieldBuilder) Description added in v1.0.2

func (fb *FieldBuilder) Description(d string) *FieldBuilder

Description sets the field description.

func (*FieldBuilder) DisplayName added in v1.0.2

func (fb *FieldBuilder) DisplayName(n string) *FieldBuilder

DisplayName sets the human-facing name.

func (*FieldBuilder) Done added in v1.0.2

func (fb *FieldBuilder) Done() *SchemaBuilder

Done commits this field and returns the schema builder.

func (*FieldBuilder) Email added in v1.0.2

func (fb *FieldBuilder) Email() *FieldBuilder

func (*FieldBuilder) EndsWith added in v1.0.2

func (fb *FieldBuilder) EndsWith(suffix string) *FieldBuilder

func (*FieldBuilder) Equals added in v1.0.2

func (fb *FieldBuilder) Equals(v string) *FieldBuilder

func (*FieldBuilder) ExactLength added in v1.0.2

func (fb *FieldBuilder) ExactLength(n int) *FieldBuilder

func (*FieldBuilder) Field added in v1.0.2

func (fb *FieldBuilder) Field(target string) *FieldBuilder

Field commits this field and starts another.

func (*FieldBuilder) HasDigit added in v1.0.2

func (fb *FieldBuilder) HasDigit() *FieldBuilder

func (*FieldBuilder) HasLower added in v1.0.2

func (fb *FieldBuilder) HasLower() *FieldBuilder

func (*FieldBuilder) HasUpper added in v1.0.2

func (fb *FieldBuilder) HasUpper() *FieldBuilder

func (*FieldBuilder) InOptions added in v1.0.2

func (fb *FieldBuilder) InOptions(opts ...string) *FieldBuilder

func (*FieldBuilder) IsArray added in v1.0.2

func (fb *FieldBuilder) IsArray() *FieldBuilder

func (*FieldBuilder) IsBase64 added in v1.0.2

func (fb *FieldBuilder) IsBase64() *FieldBuilder

func (*FieldBuilder) IsBoolean added in v1.0.2

func (fb *FieldBuilder) IsBoolean() *FieldBuilder

func (*FieldBuilder) IsCIDR added in v1.0.2

func (fb *FieldBuilder) IsCIDR() *FieldBuilder

func (*FieldBuilder) IsDate added in v1.0.2

func (fb *FieldBuilder) IsDate() *FieldBuilder

func (*FieldBuilder) IsFloat added in v1.0.2

func (fb *FieldBuilder) IsFloat() *FieldBuilder

func (*FieldBuilder) IsHTTPS added in v1.0.2

func (fb *FieldBuilder) IsHTTPS() *FieldBuilder

func (*FieldBuilder) IsHex added in v1.0.2

func (fb *FieldBuilder) IsHex() *FieldBuilder

func (*FieldBuilder) IsIP added in v1.0.2

func (fb *FieldBuilder) IsIP() *FieldBuilder

func (*FieldBuilder) IsInteger added in v1.0.2

func (fb *FieldBuilder) IsInteger() *FieldBuilder

func (*FieldBuilder) IsJSON added in v1.0.2

func (fb *FieldBuilder) IsJSON() *FieldBuilder

func (*FieldBuilder) IsNumber added in v1.0.2

func (fb *FieldBuilder) IsNumber() *FieldBuilder

func (*FieldBuilder) IsObject added in v1.0.2

func (fb *FieldBuilder) IsObject() *FieldBuilder

func (*FieldBuilder) IsString added in v1.0.2

func (fb *FieldBuilder) IsString() *FieldBuilder

func (*FieldBuilder) IsURL added in v1.0.2

func (fb *FieldBuilder) IsURL() *FieldBuilder

func (*FieldBuilder) IsUUID added in v1.0.2

func (fb *FieldBuilder) IsUUID() *FieldBuilder

func (*FieldBuilder) ItemsInOptions added in v1.0.2

func (fb *FieldBuilder) ItemsInOptions(opts ...string) *FieldBuilder

func (*FieldBuilder) LengthBetween added in v1.0.2

func (fb *FieldBuilder) LengthBetween(min, max int) *FieldBuilder

func (*FieldBuilder) Like added in v1.0.2

func (fb *FieldBuilder) Like(p string) *FieldBuilder

func (*FieldBuilder) Lower added in v1.0.2

func (fb *FieldBuilder) Lower() *FieldBuilder

func (*FieldBuilder) Max added in v1.0.2

func (fb *FieldBuilder) Max(v float64) *FieldBuilder

func (*FieldBuilder) MaxItems added in v1.0.2

func (fb *FieldBuilder) MaxItems(n int) *FieldBuilder

func (*FieldBuilder) MaxLength added in v1.0.2

func (fb *FieldBuilder) MaxLength(n int) *FieldBuilder

func (*FieldBuilder) Message added in v1.0.2

func (fb *FieldBuilder) Message(msg string) *FieldBuilder

Message sets the custom message on the most recently added validator.

func (*FieldBuilder) Meta added in v1.0.2

func (fb *FieldBuilder) Meta(m map[string]any) *FieldBuilder

Meta attaches arbitrary metadata.

func (*FieldBuilder) Min added in v1.0.2

func (fb *FieldBuilder) Min(v float64) *FieldBuilder

func (*FieldBuilder) MinItems added in v1.0.2

func (fb *FieldBuilder) MinItems(n int) *FieldBuilder

func (*FieldBuilder) MinLength added in v1.0.2

func (fb *FieldBuilder) MinLength(n int) *FieldBuilder

func (*FieldBuilder) MultipleOf added in v1.0.2

func (fb *FieldBuilder) MultipleOf(of float64) *FieldBuilder

func (*FieldBuilder) Name added in v1.0.2

func (fb *FieldBuilder) Name(n string) *FieldBuilder

Name sets the machine name.

func (*FieldBuilder) Negative added in v1.0.2

func (fb *FieldBuilder) Negative() *FieldBuilder

func (*FieldBuilder) New added in v1.0.2

func (fb *FieldBuilder) New(opts ...Option) (*Schematics, error)

New commits this field and builds a checked *Schematics.

func (*FieldBuilder) NonNegative added in v1.0.2

func (fb *FieldBuilder) NonNegative() *FieldBuilder

func (*FieldBuilder) NotEmpty added in v1.0.2

func (fb *FieldBuilder) NotEmpty() *FieldBuilder

func (*FieldBuilder) NotInOptions added in v1.0.2

func (fb *FieldBuilder) NotInOptions(opts ...string) *FieldBuilder

func (*FieldBuilder) Op added in v1.0.2

func (fb *FieldBuilder) Op(name string, args Args) *FieldBuilder

Op appends an arbitrary operator by name.

func (*FieldBuilder) Pattern added in v1.0.2

func (fb *FieldBuilder) Pattern(p string) *FieldBuilder

func (*FieldBuilder) Positive added in v1.0.2

func (fb *FieldBuilder) Positive() *FieldBuilder

func (*FieldBuilder) Regex added in v1.0.2

func (fb *FieldBuilder) Regex() *FieldBuilder

Regex marks the target as a regular expression.

func (*FieldBuilder) Replace added in v1.0.2

func (fb *FieldBuilder) Replace(old, new string) *FieldBuilder

func (*FieldBuilder) Required added in v1.0.2

func (fb *FieldBuilder) Required() *FieldBuilder

Required marks the field required.

func (*FieldBuilder) Rule added in v1.0.2

func (fb *FieldBuilder) Rule(name string, args Args) *FieldBuilder

Rule appends an arbitrary validator by name.

func (*FieldBuilder) Schema added in v1.0.2

func (fb *FieldBuilder) Schema() Schema

Schema commits this field and returns the assembled Schema.

func (*FieldBuilder) Slugify added in v1.0.2

func (fb *FieldBuilder) Slugify() *FieldBuilder

func (*FieldBuilder) StartsWith added in v1.0.2

func (fb *FieldBuilder) StartsWith(prefix string) *FieldBuilder

func (*FieldBuilder) Tags added in v1.0.2

func (fb *FieldBuilder) Tags(tags ...string) *FieldBuilder

Tags sets the field tags.

func (*FieldBuilder) ToString added in v1.0.2

func (fb *FieldBuilder) ToString() *FieldBuilder

func (*FieldBuilder) Trim added in v1.0.2

func (fb *FieldBuilder) Trim() *FieldBuilder

func (*FieldBuilder) Truncate added in v1.0.2

func (fb *FieldBuilder) Truncate(length int, suffix string) *FieldBuilder

func (*FieldBuilder) Type added in v1.0.2

func (fb *FieldBuilder) Type(t string) *FieldBuilder

Type sets the declared type (enforced when the engine has WithTypeChecks).

func (*FieldBuilder) Unique added in v1.0.2

func (fb *FieldBuilder) Unique() *FieldBuilder

func (*FieldBuilder) Upper added in v1.0.2

func (fb *FieldBuilder) Upper() *FieldBuilder

func (*FieldBuilder) When added in v1.0.2

func (fb *FieldBuilder) When(condition string, args Args) *FieldBuilder

When appends a condition that must hold for the field to run.

func (*FieldBuilder) WhenAbsent added in v1.0.2

func (fb *FieldBuilder) WhenAbsent(field string) *FieldBuilder

func (*FieldBuilder) WhenEquals added in v1.0.2

func (fb *FieldBuilder) WhenEquals(field string, value any) *FieldBuilder

func (*FieldBuilder) WhenNot added in v1.0.2

func (fb *FieldBuilder) WhenNot(condition string, args Args) *FieldBuilder

WhenNot appends a negated condition.

func (*FieldBuilder) WhenPresent added in v1.0.2

func (fb *FieldBuilder) WhenPresent(field string) *FieldBuilder

type FieldView

type FieldView struct {
	Target   string
	Name     string
	Type     string
	Required bool
	Provided bool
	Tags     []string
}

FieldView is a read-only snapshot of the field a rule is running against.

type Metrics added in v1.0.3

type Metrics struct {
	// Validations is called once per completed validation with the operation
	// ("validate", "validate_array", "validate_stream", "validate_request"),
	// the outcome ("valid" or "invalid"), and how long it took. Increment a
	// counter and observe a histogram here.
	Validations func(op, outcome string, duration time.Duration)

	// RuleFailures is called once per failing rule with the rule name, its
	// stable code, and the concrete target that failed. Prefer rule and code as
	// label values; see the note on target cardinality above before using
	// target as one.
	RuleFailures func(rule, code, target string)

	// RuleEvaluations is called once per rule evaluated, passing or failing.
	// Leave it nil unless you want a denominator for a failure rate — it fires
	// far more often than RuleFailures. Field-level checks (required,
	// dependsOn) count as evaluations, so the two callbacks are counting the
	// same population and their ratio is meaningful.
	RuleEvaluations func(rule, target string)
}

Metrics adapts the observer hook to a metrics backend. Set only the callbacks you want; a nil callback is skipped. Every callback may be invoked concurrently, which Prometheus collectors already tolerate.

Example

ExampleMetrics wires validation into a metrics backend. The callbacks below print instead of incrementing, but the shape is exactly what a Prometheus wiring looks like:

m := schematics.Metrics{
    Validations: func(op, outcome string, d time.Duration) {
        validationsTotal.WithLabelValues(op, outcome).Inc()
        validationSeconds.WithLabelValues(op).Observe(d.Seconds())
    },
    RuleFailures: func(rule, code, _ string) {
        ruleFailuresTotal.WithLabelValues(rule, code).Inc()
    },
}
s := schematics.New(schematics.WithObserver(m.Observer()))

Note that the target is deliberately dropped from the label set: a wildcard target expands to one concrete key per array element, which would create a time series per index.

package main

import (
	"fmt"
	"sort"
	"strings"
	"time"

	schematics "github.com/ashbeelghouri/json-schematics-v2"
)

const orderSchema = `{"fields":[
	{"target":"customer.email","required":true,"validate":[{"rule":"email"}]},
	{"target":"quantity","validate":[{"rule":"min","args":{"min":1}}]}
]}`

func main() {
	var lines []string
	m := schematics.Metrics{
		Validations: func(op, outcome string, d time.Duration) {
			lines = append(lines, fmt.Sprintf("validations_total{op=%q,outcome=%q} +1", op, outcome))
		},
		RuleFailures: func(rule, code, _ string) {
			lines = append(lines, fmt.Sprintf("rule_failures_total{rule=%q,code=%q} +1", rule, code))
		},
	}

	s := schematics.New(schematics.WithObserver(m.Observer()))
	if err := s.LoadBytes([]byte(orderSchema)); err != nil {
		panic(err)
	}

	_ = s.Validate(map[string]any{"quantity": 5}) // missing email
	_ = s.Validate(map[string]any{                // valid
		"customer": map[string]any{"email": "ada@example.com"},
		"quantity": 5,
	})

	sort.Strings(lines)
	fmt.Println(strings.Join(lines, "\n"))
}
Output:
rule_failures_total{rule="required",code="field.required"} +1
validations_total{op="validate",outcome="invalid"} +1
validations_total{op="validate",outcome="valid"} +1

func (Metrics) Observer added in v1.0.3

func (m Metrics) Observer() Observer

Observer returns an Observer that drives m's callbacks. It is the value to hand to WithObserver:

m := schematics.Metrics{ ... }
s := schematics.New(schematics.WithObserver(m.Observer()))

type Observer added in v1.0.3

type Observer interface {
	Observe(Event)
}

Observer receives events from the validation engine. Observe is called synchronously on the validating goroutine, so an implementation that does anything slow should hand off to a buffered channel rather than block the caller. It may be called concurrently when a shared Schematics validates on several goroutines, so it must be safe for concurrent use.

A panic in Observe is not swallowed: it surfaces to whoever called Validate, so a bug in an observer fails loudly rather than silently corrupting the numbers it reports. On the streaming path, where rows are validated on goroutines this package owns, the panic is captured and re-raised on the caller's goroutine wrapped in a *StreamPanic — see that type for why the wrapper exists.

type ObserverFunc added in v1.0.3

type ObserverFunc func(Event)

ObserverFunc adapts a plain function to the Observer interface.

func (ObserverFunc) Observe added in v1.0.3

func (f ObserverFunc) Observe(e Event)

Observe implements Observer.

type Operation added in v1.0.3

type Operation string

Operation names the entry point that produced an event.

const (
	// OpValidate is a Validate/ValidateCtx/ValidateBytes call on an object.
	OpValidate Operation = "validate"
	// OpValidateArray is a Validate call on an array of objects.
	OpValidateArray Operation = "validate_array"
	// OpValidateStream is one row of a ValidateStream call.
	OpValidateStream Operation = "validate_stream"
	// OpValidateRequest is an API.ValidateRequest call.
	OpValidateRequest Operation = "validate_request"
)

type Operator

type Operator func(value any, args Args, ctx *Context) (any, error)

Operator transforms a value and returns the replacement. Returning an error aborts the whole Operate call.

type OperatorRef

type OperatorRef struct {
	Op   string `json:"op"`
	Args Args   `json:"args,omitempty"`
}

OperatorRef references a registered operator and its arguments.

type Option

type Option func(*Schematics)

Option configures a Schematics at construction time.

func WithArrayIDKey

func WithArrayIDKey(key string) Option

WithArrayIDKey sets the flattened key whose value identifies each row when validating an array of objects.

func WithCatalog added in v1.0.3

func WithCatalog(cat *Catalog) Option

WithCatalog attaches an external message catalog (see LoadCatalogFile / LoadCatalogTOMLFile) that ValidationError.Message consults when a failing rule has no inline "message"/"messages" of its own for the requested locale. An explicit per-rule schema message always overrides the catalog.

func WithCollectAll added in v1.0.2

func WithCollectAll() Option

WithCollectAll makes Validate report every failing rule on each field rather than stopping at the first failure. Useful for form validation where the caller wants to show all problems at once.

func WithDB

func WithDB(db map[string]any) Option

WithDB seeds the shared DB that is passed to every rule.

func WithLocale

func WithLocale(locale string) Option

WithLocale sets the default locale for error messages.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger attaches a slog.Logger. By default logs are discarded.

func WithMaxBodyBytes added in v1.0.2

func WithMaxBodyBytes(n int64) Option

WithMaxBodyBytes caps how many bytes the API layer reads from a request body, guarding against unbounded-read denial of service. A value <= 0 means unlimited. It has no effect on the plain Validate path.

func WithObserver added in v1.0.3

func WithObserver(o Observer) Option

WithObserver attaches an observer that receives an event per validation and per rule evaluated. Passing nil leaves observation off, which is the default and costs nothing: with no observer the engine never reads the clock and never builds an event.

s := schematics.New(schematics.WithObserver(
    schematics.ObserverFunc(func(e schematics.Event) {
        if e.Kind == schematics.EventRule && e.Failed {
            log.Printf("%s rejected %s", e.Rule, e.Target)
        }
    })))
Example

ExampleWithObserver watches validation as it happens. The observer is called once per rule and once per completed validation; with no observer attached none of this work happens at all.

package main

import (
	"fmt"

	schematics "github.com/ashbeelghouri/json-schematics-v2"
)

const orderSchema = `{"fields":[
	{"target":"customer.email","required":true,"validate":[{"rule":"email"}]},
	{"target":"quantity","validate":[{"rule":"min","args":{"min":1}}]}
]}`

func main() {
	s := schematics.New(schematics.WithObserver(
		schematics.ObserverFunc(func(e schematics.Event) {
			switch e.Kind {
			case schematics.EventRule:
				if e.Failed {
					fmt.Printf("rule %s rejected %s (%s)\n", e.Rule, e.Target, e.Code)
				}
			case schematics.EventValidation:
				fmt.Printf("%s finished: %d row(s), %d error(s)\n", e.Op, e.Rows, e.Errors)
			}
		})))
	if err := s.LoadBytes([]byte(orderSchema)); err != nil {
		panic(err)
	}

	_ = s.Validate(map[string]any{"customer": map[string]any{"email": "nope"}, "quantity": 3})
}
Output:
rule email rejected customer.email (rule.email)
validate finished: 1 row(s), 1 error(s)

func WithSeparator

func WithSeparator(sep string) Option

WithSeparator sets the key separator used when flattening documents.

func WithTracer added in v1.0.3

func WithTracer(t Tracer) Option

WithTracer attaches a tracer. Validation then runs inside a span named after the operation ("schematics.validate", "schematics.validate_array", ...) with attributes for the fields in the schema, rows validated, and failures found. Passing nil leaves tracing off, which is the default and costs nothing: with no tracer no span is started and no context is derived.

s := schematics.New(schematics.WithTracer(myOTelAdapter{tracer}))
Example

ExampleWithTracer traces a validation as a span inside whatever trace the caller is already running. Without a tracer no span is started.

package main

import (
	"context"
	"fmt"

	schematics "github.com/ashbeelghouri/json-schematics-v2"
)

const orderSchema = `{"fields":[
	{"target":"customer.email","required":true,"validate":[{"rule":"email"}]},
	{"target":"quantity","validate":[{"rule":"min","args":{"min":1}}]}
]}`

// printTracer is the shape an OpenTelemetry adapter takes. A real one wraps
// trace.Tracer:
//
//	type otelTracer struct{ t trace.Tracer }
//
//	func (o otelTracer) Start(ctx context.Context, name string) (context.Context, schematics.Span) {
//	    ctx, span := o.t.Start(ctx, name)
//	    return ctx, otelSpan{span}
//	}
//
//	type otelSpan struct{ s trace.Span }
//
//	func (o otelSpan) End() { o.s.End() }
//	func (o otelSpan) SetAttributes(attrs ...schematics.Attribute) {
//	    kvs := make([]attribute.KeyValue, 0, len(attrs))
//	    for _, a := range attrs {
//	        switch v := a.Value.(type) {
//	        case string:
//	            kvs = append(kvs, attribute.String(a.Key, v))
//	        case int:
//	            kvs = append(kvs, attribute.Int(a.Key, v))
//	        case bool:
//	            kvs = append(kvs, attribute.Bool(a.Key, v))
//	        }
//	    }
//	    o.s.SetAttributes(kvs...)
//	}
type printTracer struct{}

func (printTracer) Start(ctx context.Context, name string) (context.Context, schematics.Span) {
	fmt.Println("start", name)
	return ctx, printSpan{}
}

type printSpan struct{}

func (printSpan) SetAttributes(attrs ...schematics.Attribute) {
	for _, a := range attrs {
		if a.Key == "schematics.errors" {
			fmt.Println("  errors =", a.Value)
		}
	}
}

func (printSpan) End() { fmt.Println("end") }

func main() {
	s := schematics.New(schematics.WithTracer(printTracer{}))
	if err := s.LoadBytes([]byte(orderSchema)); err != nil {
		panic(err)
	}
	_ = s.ValidateCtx(context.Background(), map[string]any{"quantity": 1})
}
Output:
start schematics.validate
  errors = 1
end

func WithTypeChecks added in v1.0.2

func WithTypeChecks() Option

WithTypeChecks makes a field's "type" enforce a matching built-in check (string, number, integer, boolean, array, date, object) before its own validators run. Without this option, "type" is treated as documentation only, preserving the default behavior.

type Problem added in v1.0.3

type Problem struct {
	// Type is a URI identifying the problem kind. Defaults to "about:blank",
	// which the RFC defines as "no more specific type than the status code".
	Type string `json:"type"`
	// Title is a short, human-readable summary of the problem kind. It should
	// not change from occurrence to occurrence.
	Title string `json:"title"`
	// Status is the HTTP status code, repeated here per the RFC so the document
	// is self-contained when it is logged away from its response.
	Status int `json:"status"`
	// Detail is a human-readable explanation specific to this occurrence.
	Detail string `json:"detail,omitempty"`
	// Instance is a URI identifying this specific occurrence, typically the
	// request path.
	Instance string `json:"instance,omitempty"`
	// Errors is the per-failure breakdown (an RFC 7807 extension member).
	Errors []ProblemError `json:"errors,omitempty"`
}

Problem is an RFC 7807 problem details document. The first five fields are the members the RFC defines; Errors is an extension member carrying the per-failure breakdown.

func NewProblem added in v1.0.3

func NewProblem(err error, opts ...ProblemOption) *Problem

NewProblem builds an RFC 7807 document from an error returned by Validate, ValidateRequest, or ValidateStream. It understands *ValidationErrors and *SchemaError; any other non-nil error becomes a 500 with its message as the detail. It returns nil when err is nil, so a handler can write a problem response only when there is one.

func (*Problem) Write added in v1.0.3

func (p *Problem) Write(w http.ResponseWriter) error

Write sends p as an application/problem+json response. It sets the status code and content type, so it must be called before anything else writes to w.

type ProblemError added in v1.0.3

type ProblemError struct {
	// Pointer is an RFC 6901 JSON Pointer to the offending value, relative to
	// the request body. Empty for failures that are not about a location in the
	// document, such as an unmatched route.
	Pointer string `json:"pointer"`
	// Target is the flattened key that failed, kept alongside the pointer
	// because it is what the schema itself is written in terms of.
	Target string `json:"target,omitempty"`
	// Code is the stable machine-readable failure code, e.g. "rule.email".
	Code string `json:"code"`
	// Rule is the validator that rejected the value.
	Rule string `json:"rule,omitempty"`
	// Detail is the localized human-readable message.
	Detail string `json:"detail"`
	// RowID identifies the array row, for array payloads.
	RowID string `json:"id,omitempty"`
}

ProblemError is one failure inside a Problem's errors array.

type ProblemOption added in v1.0.3

type ProblemOption func(*problemConfig)

ProblemOption customizes a generated Problem.

func WithProblemDetail added in v1.0.3

func WithProblemDetail(detail string) ProblemOption

WithProblemDetail overrides the derived detail line.

func WithProblemInstance added in v1.0.3

func WithProblemInstance(uri string) ProblemOption

WithProblemInstance sets the "instance" URI, usually the request path.

func WithProblemLocale added in v1.0.3

func WithProblemLocale(locale string) ProblemOption

WithProblemLocale selects the locale used to render each failure's message. Defaults to the engine's locale when called through API.WriteProblem, and to the library default otherwise.

func WithProblemStatus added in v1.0.3

func WithProblemStatus(status int) ProblemOption

WithProblemStatus overrides the derived HTTP status code.

func WithProblemTitle added in v1.0.3

func WithProblemTitle(title string) ProblemOption

WithProblemTitle overrides the derived title.

func WithProblemType added in v1.0.3

func WithProblemType(uri string) ProblemOption

WithProblemType sets the "type" URI identifying the problem kind.

type RowResult added in v1.0.3

type RowResult struct {
	// Index is the row's zero-based position in the array.
	Index int
	// RowID identifies the row in error messages. It is the value of the
	// schema's arrayIdKey when the row carries one, and the row's index
	// otherwise — the same rule the non-streaming array path uses.
	RowID string
	// Errors holds every validation failure for this row, or nil when the row
	// is valid.
	Errors *ValidationErrors
	// contains filtered or unexported fields
}

RowResult is the outcome of validating a single row of a streamed array.

func (RowResult) Valid added in v1.0.3

func (r RowResult) Valid() bool

Valid reports whether the row passed validation.

type Rule

type Rule func(value any, args Args, ctx *Context) error

Rule validates a value. It returns nil when the value is acceptable, or an error describing why it is not. Rules must never panic: use the typed Args accessors and the value-inspection helpers, both of which report bad input as errors.

type RuleRef

type RuleRef struct {
	Rule     string            `json:"rule"`
	Args     Args              `json:"args,omitempty"`
	Message  string            `json:"message,omitempty"`
	Messages map[string]string `json:"messages,omitempty"`
}

RuleRef references a registered validator and its per-use configuration.

type Schema

type Schema struct {
	Version    string         `json:"version,omitempty"`
	Separator  string         `json:"separator,omitempty"`
	ArrayIDKey string         `json:"arrayIdKey,omitempty"`
	Locale     string         `json:"locale,omitempty"`
	DB         map[string]any `json:"db,omitempty"`
	Fields     []Field        `json:"fields"`
}

Schema is the declarative document that drives validation and operation.

Example (JSON):

{
  "version": "2.0",
  "separator": ".",
  "arrayIdKey": "id",
  "locale": "en",
  "db": { "minAge": 18 },
  "fields": [
    {
      "target": "user.profile.name",
      "type": "string",
      "required": true,
      "validate": [ { "rule": "minLength", "args": { "min": 2 } } ],
      "operate":  [ { "op": "trim" }, { "op": "capitalize" } ]
    }
  ]
}

type SchemaBuilder added in v1.0.2

type SchemaBuilder struct {
	// contains filtered or unexported fields
}

SchemaBuilder accumulates schema-level settings and fields.

func NewSchema added in v1.0.2

func NewSchema() *SchemaBuilder

NewSchema starts a fluent schema definition.

func (*SchemaBuilder) ArrayIDKey added in v1.0.2

func (b *SchemaBuilder) ArrayIDKey(k string) *SchemaBuilder

ArrayIDKey sets the per-row identifier key for array validation.

func (*SchemaBuilder) DB added in v1.0.2

func (b *SchemaBuilder) DB(m map[string]any) *SchemaBuilder

DB seeds the shared DB baked into the schema.

func (*SchemaBuilder) Field added in v1.0.2

func (b *SchemaBuilder) Field(target string) *FieldBuilder

Field begins a new field targeting target.

func (*SchemaBuilder) Locale added in v1.0.2

func (b *SchemaBuilder) Locale(l string) *SchemaBuilder

Locale sets the default error locale.

func (*SchemaBuilder) New added in v1.0.2

func (b *SchemaBuilder) New(opts ...Option) (*Schematics, error)

New builds a *Schematics from the schema, applies opts, and runs Check so a malformed schema fails fast.

func (*SchemaBuilder) Schema added in v1.0.2

func (b *SchemaBuilder) Schema() Schema

Schema returns the assembled Schema value.

func (*SchemaBuilder) Separator added in v1.0.2

func (b *SchemaBuilder) Separator(sep string) *SchemaBuilder

Separator sets the flattening separator.

type SchemaError

type SchemaError struct {
	// Problems holds one human-readable description per problem found.
	Problems []string
	// Details holds the same problems in machine-readable form, in the same
	// order as Problems. Errors raised before this field existed may leave it
	// nil, so treat an empty Details with a non-empty Problems as "uncoded"
	// rather than as "no problems".
	Details []SchemaProblem
}

SchemaError is returned when a schema references a rule, operator, or condition that is not registered, or is otherwise malformed. It is distinct from ValidationErrors, which reports problems with the data.

func (*SchemaError) Codes added in v1.0.3

func (e *SchemaError) Codes() []Code

Codes returns the code of every problem, in order. Useful for asserting in a test that a schema failed for the reason you expected rather than by matching on message text.

func (*SchemaError) Error

func (e *SchemaError) Error() string

Error implements the error interface. It tolerates a nil receiver, because a typed-nil *SchemaError stored in an error interface is non-nil to the caller and would otherwise panic the first time anything printed it.

type SchemaProblem added in v1.0.3

type SchemaProblem struct {
	Code    Code   `json:"code"`
	Target  string `json:"target,omitempty"`
	Message string `json:"message"`
}

SchemaProblem is one machine-readable entry in a SchemaError: what kind of problem it is, which target it was found on (empty when the problem is not tied to a single field), and the human-readable description.

type Schematics

type Schematics struct {
	// contains filtered or unexported fields
}

Schematics is the entry point: it holds a schema, the registries of validators/operators/conditions (pre-loaded with the built-ins), and configuration. Create one with New, load a schema, then call Validate or Operate.

A Schematics is safe for concurrent use by multiple goroutines once its schema is loaded and any custom rules are registered: Validate, ValidateCtx, Operate, ValidateBytes and the API layer may all be called in parallel on a shared instance. Registering rules or loading a new schema is not safe to do concurrently with validation — do that during setup, before sharing.

func ImportSchema added in v1.0.2

func ImportSchema(b []byte, opts ...Option) (*Schematics, error)

ImportSchema is a convenience constructor for callers who start from raw schema bytes (a config file already read into memory, a network response, an embedded fixture): it combines New and LoadBytes into one call.

s, err := schematics.ImportSchema(schemaBytes)
if err != nil {
    log.Fatal(err)
}

Any options are applied the same way they are for New, before the schema is loaded.

func New

func New(opts ...Option) *Schematics

New creates a Schematics with every built-in validator, operator, and condition registered, then applies the given options.

func (*Schematics) Check

func (s *Schematics) Check() error

Check verifies that every rule, operator, and condition referenced by the schema is registered and that fields are well formed. It returns a *SchemaError describing all problems, or nil.

func (*Schematics) ConditionNames

func (s *Schematics) ConditionNames() []string

ConditionNames returns the names of every registered condition.

func (*Schematics) LoadBytes

func (s *Schematics) LoadBytes(b []byte) error

LoadBytes parses a JSON schema from b.

func (*Schematics) LoadFile

func (s *Schematics) LoadFile(path string) error

LoadFile reads and parses a JSON schema file.

func (*Schematics) LoadMap

func (s *Schematics) LoadMap(m any) error

LoadMap parses a schema from an in-memory value (a map or struct) by round-tripping it through JSON.

func (*Schematics) Operate

func (s *Schematics) Operate(data any) (any, error)

Operate applies each field's operator chain to the matching values in data and returns the transformed document. data may be a single object or an array of objects. Validators are not run.

func (*Schematics) OperateCtx

func (s *Schematics) OperateCtx(ctx context.Context, data any) (any, error)

OperateCtx is Operate with a caller-supplied context.Context.

func (*Schematics) OperatorNames

func (s *Schematics) OperatorNames() []string

OperatorNames returns the names of every registered operator.

func (*Schematics) RegisterCondition

func (s *Schematics) RegisterCondition(name string, fn Condition) *Schematics

RegisterCondition adds or replaces a named condition.

func (*Schematics) RegisterOperator

func (s *Schematics) RegisterOperator(name string, fn Operator) *Schematics

RegisterOperator adds or replaces a named operator.

func (*Schematics) RegisterRule

func (s *Schematics) RegisterRule(name string, fn Rule) *Schematics

RegisterRule adds or replaces a named validator. It is safe to call after loading a schema and before Validate.

The name is a public identifier, not just a lookup key: a failure from this rule reports the stable code "rule."+name (see CODES.md), which is what dashboards group by and clients switch on. Renaming a registered rule therefore renames its error code and silently breaks anything downstream that was matching on it. Pick the name as deliberately as you would an exported symbol.

func (*Schematics) RuleNames

func (s *Schematics) RuleNames() []string

RuleNames returns the names of every registered validator.

func (*Schematics) Schema

func (s *Schematics) Schema() Schema

Schema returns the currently loaded schema.

func (*Schematics) SetSchema

func (s *Schematics) SetSchema(schema Schema) *Schematics

SetSchema installs a schema value directly.

func (*Schematics) Validate

func (s *Schematics) Validate(data any) error

Validate checks data against the loaded schema. data may be a single object (map or struct) or an array of objects. It returns nil when the data is valid, a *ValidationErrors when it is not, or a *SchemaError when the schema itself references something unregistered.

Validate never modifies data. It may, however, read it without copying: when data is already made of JSON-native values (as it is when it came from json.Unmarshal), validation walks the caller's own maps and slices instead of round-tripping them through JSON first. Do not mutate data concurrently with a Validate call on it.

func (*Schematics) ValidateBytes added in v1.0.2

func (s *Schematics) ValidateBytes(b []byte, isArray bool) error

ValidateBytes parses raw JSON data bytes and validates them against the loaded schema, saving the caller a manual json.Unmarshal before calling Validate. isArray tells it whether to parse b as a single JSON object (false) or as an array of objects (true) — pass it explicitly rather than relying on shape-sniffing, so a payload that doesn't match the expected shape fails with a clear parse error instead of silently going through Validate's try-object-then-array fallback.

func (*Schematics) ValidateBytesCtx added in v1.0.2

func (s *Schematics) ValidateBytesCtx(ctx context.Context, b []byte, isArray bool) error

ValidateBytesCtx is ValidateBytes with a caller-supplied context.Context, made available to every rule via Context.Ctx.

func (*Schematics) ValidateCtx

func (s *Schematics) ValidateCtx(ctx context.Context, data any) error

ValidateCtx is Validate with a caller-supplied context.Context, made available to every rule via Context.Ctx.

func (*Schematics) ValidateSchema added in v1.0.1

func (s *Schematics) ValidateSchema(sample any, ignoreTargets ...string) error

ValidateSchema runs every check that Check does — unknown rule/operator/ condition names, empty targets, duplicate targets — and additionally verifies that every field's target actually resolves against sample once flattened. sample should be a representative example of the data you intend to validate: a test fixture, a golden payload, or real (secret- stripped) production data.

Check alone cannot catch a typo inside a target's value. Given `"target": "mane"` instead of `"name"`, "mane" is a perfectly valid string, so Check has nothing to object to — the field just silently never matches anything at runtime. ValidateSchema closes that gap by matching every target against real data and flagging the ones that match nothing.

A typo in the JSON *key* itself — `"tagret"` instead of `"target"` — never needs sample data to catch: encoding/json silently drops an unrecognized key, so Target is left as "" and Check already reports "field #N has an empty target". ValidateSchema surfaces that too, since it runs Check first.

Pass ignoreTargets for fields that are legitimately allowed to be missing from every sample you have — an optional field with no example value in your fixtures, say — so they are not flagged as suspected typos.

Wildcard (target containing "*") and targetRegex fields are matched the same way they are at validation time: if nothing in sample matches the pattern, the field is flagged. If that's simply because your sample doesn't happen to contain that shape, add the target to ignoreTargets rather than treating the result as a bug in the schema.

ValidateSchema does not replace tests. It is meant to run in CI or a unit test alongside a fixture, so drift between your schema and your real data shape fails the build instead of failing silently in production.

func (*Schematics) ValidateStream added in v1.0.3

func (s *Schematics) ValidateStream(r io.Reader, opts ...StreamOption) error

ValidateStream reads a JSON array of objects from r and validates every row against the loaded schema, decoding one row at a time rather than holding the whole array in memory.

It returns nil when every row is valid, a *ValidationErrors aggregating every failure in row order when some row is not, or a *SchemaError when the schema itself is unsound. A malformed stream returns a plain error describing where decoding failed.

Errors are accumulated in memory, so a stream in which nearly every row fails will still grow with the size of the input. Use ValidateStreamEach when you need to handle failures as they arrive rather than collecting them.

A panic in a custom rule, condition, or observer is re-raised on the calling goroutine as a *StreamPanic, so a caller's recover sees it exactly as it would on the non-streaming path.

err := s.ValidateStream(resp.Body, schematics.WithConcurrency(8))

func (*Schematics) ValidateStreamCtx added in v1.0.3

func (s *Schematics) ValidateStreamCtx(ctx context.Context, r io.Reader, opts ...StreamOption) error

ValidateStreamCtx is ValidateStream with a caller-supplied context.Context. Cancelling the context stops decoding and validation and returns the context's error.

Cancellation takes effect between reads of r, not during one. encoding/json offers no way to interrupt a decoder mid-read, so if r is a network body that stalls, this call stays blocked in that Read until it returns or the connection times out. Give the reader its own deadline when that matters — http.Client.Timeout, or a net.Conn SetReadDeadline — rather than relying on the context alone.

func (*Schematics) ValidateStreamEach added in v1.0.3

func (s *Schematics) ValidateStreamEach(ctx context.Context, r io.Reader, fn func(RowResult) error, opts ...StreamOption) error

ValidateStreamEach reads a JSON array of objects from r and calls fn once per row, in row order, as each row's result becomes available. It is the streaming API in its general form: ValidateStream is this function with an fn that collects everything.

Because fn sees each result as it arrives and nothing is retained afterwards, this is the form to use when the array is too large for its *errors* to fit in memory, or when failures should be acted on immediately — written to a log, streamed to a client, counted.

Returning a non-nil error from fn stops the walk early and that error is returned to the caller. Rows already in flight are abandoned; fn is not called again. Use it to implement a failure budget:

var bad int
err := s.ValidateStreamEach(ctx, r, func(res schematics.RowResult) error {
    if res.Valid() {
        return nil
    }
    if bad++; bad > 100 {
        return fmt.Errorf("too many invalid rows, stopping at %d", res.Index)
    }
    log.Printf("row %s: %v", res.RowID, res.Errors)
    return nil
}, schematics.WithConcurrency(4))

fn is always called from a single goroutine, so it does not need to be safe for concurrent use even when rows are validated in parallel.

type SlogObserver added in v1.0.3

type SlogObserver struct {
	// contains filtered or unexported fields
}

SlogObserver logs events to a *slog.Logger: validation events at level, and failing rules at level too, with the target, rule and code as attributes. Passing rules are not logged — a log line per passing rule is noise at any realistic volume, and the count belongs in a metric rather than a log.

func NewSlogObserver added in v1.0.3

func NewSlogObserver(logger *slog.Logger, level slog.Level) *SlogObserver

NewSlogObserver builds an Observer that writes to logger at level. A nil logger falls back to slog.Default().

s := schematics.New(schematics.WithObserver(
    schematics.NewSlogObserver(slog.Default(), slog.LevelDebug)))

func (*SlogObserver) Observe added in v1.0.3

func (o *SlogObserver) Observe(e Event)

Observe implements Observer.

type Span added in v1.0.3

type Span interface {
	// SetAttributes records attributes on the span.
	SetAttributes(attrs ...Attribute)
	// End marks the span complete. It is called exactly once per span.
	End()
}

Span is one unit of work in a trace. It mirrors the subset of the OpenTelemetry span API this library needs; an adapter satisfying it is a handful of forwarding methods. Its methods may be called concurrently; a panic in one surfaces to the caller of Validate, the same way a panic in Observer.Observe does.

type StreamOption added in v1.0.3

type StreamOption func(*streamConfig)

StreamOption configures a single streaming validation call.

func WithConcurrency added in v1.0.3

func WithConcurrency(n int) StreamOption

WithConcurrency sets how many rows may be validated in parallel, which is also the bound on how many rows are held in memory at once. The default is 1: rows are validated one at a time, in order, and memory stays flat regardless of the size of the input.

Raising it trades memory for throughput and is worth doing when rules are expensive — a rule that calls out to a database, say. It does not change results: errors are always reported in row order, and for any given input the output is identical at every concurrency level. Values below 1 are treated as 1.

type StreamPanic added in v1.0.3

type StreamPanic struct {
	// Value is the value the original panic was raised with.
	Value any
	// Stack is the stack trace captured at the point of recovery, on the
	// goroutine where the panic actually happened. It is deliberately not part
	// of Error(): anything that logs an error string would then dump a full
	// stack inline, and a caller who wants it can read this field.
	Stack []byte

	// Suppressed is the error the call would have returned had it not panicked
	// — a decode failure, a context cancellation, or an error the caller's own
	// ValidateStreamEach callback returned before the panic surfaced.
	//
	// A panic outranks an error because they are different kinds of news: an
	// error says the data was bad, a panic says the code is broken, and only
	// one of those can be handled by retrying with better input. But the error
	// is real and discarding it would hide, say, a truncated stream behind a
	// rule's nil dereference — so it travels here rather than being dropped.
	Suppressed error
}

StreamPanic carries a panic that happened on a goroutine this package owns, so it can be re-raised on the caller's goroutine where their recover can actually see it.

Streaming validates rows on worker goroutines. A panic there — from a custom rule, a condition, or an Observer — cannot be recovered by the caller, and an unrecovered panic in any goroutine terminates the process. That would make switching from Validate to ValidateStream turn a recoverable per-request failure into a guaranteed crash, which is not a trade a library gets to make for its callers. So the worker recovers, and the consumer re-panics.

The re-panicked value is this wrapper rather than the original, because the original panic's stack is the useful part and there is nowhere else to put it: by the time the value reaches the caller's goroutine the stack it came from is gone. Value is the original panic value and Unwrap returns it when it is an error, so errors.As and errors.Is still work through it.

func (*StreamPanic) Error added in v1.0.3

func (p *StreamPanic) Error() string

Error implements the error interface. It is one line on purpose; read Stack for the trace and Suppressed for anything that was outranked.

func (*StreamPanic) Unwrap added in v1.0.3

func (p *StreamPanic) Unwrap() error

Unwrap exposes the original panic value when it was an error, so a caller that recovers can errors.As it as whatever it originally was.

type Tracer added in v1.0.3

type Tracer interface {
	Start(ctx context.Context, name string) (context.Context, Span)
}

Tracer starts spans. Start returns a context carrying the new span, which the engine threads into every rule via Context.Ctx, so a rule that calls out to a database can start its own child span and have it nest correctly.

An implementation may decline to start a span — a sampler dropping it, say — by returning a nil Span; the engine treats that as tracing being off for that call. Returning a nil context is treated as "no derived context" and the original is kept, rather than handing nil to every rule.

type ValidationError

type ValidationError struct {
	// Target is the concrete flattened key that failed, e.g. "user.profile.name".
	Target string
	// Rule is the name of the validator (or "required"/"dependsOn") that failed.
	Rule string
	// Code is the stable machine-readable identifier for this failure, e.g.
	// "rule.minLength" or "field.required". Unlike the message, it is safe to
	// switch on and to use as a metric label; see codes.go and CODES.md.
	Code Code
	// Value is the offending value.
	Value any
	// RowID identifies the array row for array inputs; empty for plain objects.
	RowID string
	// contains filtered or unexported fields
}

ValidationError describes a single rule that failed on a single target value.

func (*ValidationError) Error

func (e *ValidationError) Error() string

Error implements the error interface using the default locale.

func (*ValidationError) Format

func (e *ValidationError) Format(locale, format string) string

Format renders the error using a template. Recognized tokens are %message, %target, %pointer, %rule (alias %validator), %code, %value and %id.

func (*ValidationError) MarshalJSON

func (e *ValidationError) MarshalJSON() ([]byte, error)

MarshalJSON renders the error as a stable JSON object.

func (*ValidationError) Message

func (e *ValidationError) Message(locale string) string

Message returns the message for locale. Resolution order: an explicit per-error message for locale (set via the schema's RuleRef.Message / Messages) always wins; then, if the engine has a Catalog attached (see WithCatalog), a rendered catalog template for this rule; then the default-locale/fallback message; then a generated description. A schema message always overrides the catalog — the catalog fills gaps, it doesn't replace explicit per-rule wording.

func (*ValidationError) Pointer added in v1.0.3

func (e *ValidationError) Pointer() string

Pointer returns an RFC 6901 JSON Pointer to the value that failed, relative to the document passed to Validate.

target "user.email", plain object    -> "/user/email"
target "user.email", array row 2     -> "/2/user/email"
target "tags.0",     plain object    -> "/tags/0"

It returns "" — the pointer to the whole document — for failures that are not about a location in the data, such as an unmatched route or an oversized request body. A wildcard or regex target that never matched anything has no concrete location either; its pattern is returned as written, escaped, since that is the most specific thing that can honestly be said about where the value should have been.

Example

ExampleValidationError_Pointer shows the JSON Pointer each failure carries. It addresses the offending value in the document the caller submitted, so a browser or API client can highlight the right field without knowing anything about how the schema flattens keys.

package main

import (
	"errors"
	"fmt"

	schematics "github.com/ashbeelghouri/json-schematics-v2"
)

const orderSchema = `{"fields":[
	{"target":"customer.email","required":true,"validate":[{"rule":"email"}]},
	{"target":"quantity","validate":[{"rule":"min","args":{"min":1}}]}
]}`

func main() {
	s, err := schematics.ImportSchema([]byte(orderSchema))
	if err != nil {
		panic(err)
	}

	verr := s.Validate(map[string]any{
		"customer": map[string]any{"email": "not-an-email"},
		"quantity": 0,
	})

	var ve *schematics.ValidationErrors
	if errors.As(verr, &ve) {
		for _, e := range ve.Errors {
			fmt.Printf("%s\t%s\n", e.Pointer(), e.Code)
		}
	}
}
Output:
/customer/email	rule.email
/quantity	rule.min

type ValidationErrors

type ValidationErrors struct {
	Errors []*ValidationError
}

ValidationErrors is the aggregate error returned by Validate. It implements the error interface, so callers can use errors.As to recover it.

func (*ValidationErrors) Add

func (es *ValidationErrors) Add(e *ValidationError)

Add appends a non-nil error.

func (*ValidationErrors) Error

func (es *ValidationErrors) Error() string

Error implements the error interface.

func (*ValidationErrors) ForTarget

func (es *ValidationErrors) ForTarget(target string) []*ValidationError

ForTarget returns the subset of errors whose Target equals target.

func (*ValidationErrors) HasErrors

func (es *ValidationErrors) HasErrors() bool

HasErrors reports whether any errors were collected.

func (*ValidationErrors) Len

func (es *ValidationErrors) Len() int

Len returns the number of collected errors.

func (*ValidationErrors) Messages

func (es *ValidationErrors) Messages(locale string) []string

Messages returns just the localized messages.

func (*ValidationErrors) Pointers added in v1.0.3

func (es *ValidationErrors) Pointers() []string

Pointers returns the JSON Pointer of every collected error, in order.

func (*ValidationErrors) Strings

func (es *ValidationErrors) Strings(locale, format string) []string

Strings renders every error with the given locale and format template.

type WhenNode added in v1.0.3

type WhenNode struct {
	// Leaf fields — set Condition (and optionally Args/Negate) to reference a
	// registered condition. Leave Any/All/Not nil.
	Condition string `json:"condition,omitempty"`
	Args      Args   `json:"args,omitempty"`
	Negate    bool   `json:"negate,omitempty"`

	// Group fields — set exactly one to compose child nodes instead of
	// referencing a condition directly. Leave Condition empty.
	Any []WhenNode `json:"any,omitempty"`
	All []WhenNode `json:"all,omitempty"`
	Not *WhenNode  `json:"not,omitempty"`
}

WhenNode is one entry in a Field's "when" list: either a leaf reference to a registered condition, or a boolean-composition group (any/all/not) of other WhenNodes. Go's encoding/json handles the recursive Any/All/Not fields natively, so a nested group is just more JSON, not a special case.

Leaf example:

{ "condition": "fieldPresent", "args": { "field": "user.email" } }

Group examples:

{ "any": [ {"condition":"fieldEquals","args":{"field":"plan","value":"pro"}},
           {"condition":"fieldEquals","args":{"field":"plan","value":"team"}} ] }
{ "all": [ {"condition":"fieldPresent","args":{"field":"a"}},
           {"condition":"fieldPresent","args":{"field":"b"}} ] }
{ "not": { "condition": "fieldPresent", "args": { "field": "legacyMode" } } }

A leaf and a group are mutually exclusive on one node; Check reports it as a malformed schema if both (or none) are set. Negate applies uniformly to either kind, inverting whatever the node otherwise evaluates to.

Directories

Path Synopsis
cmd
schematics command
Command schematics is a CLI wrapper around the json-schematics-v2 library so schemas and data can be checked from CI pipelines, Makefiles, and GitHub Actions without writing any Go code.
Command schematics is a CLI wrapper around the json-schematics-v2 library so schemas and data can be checked from CI pipelines, Makefiles, and GitHub Actions without writing any Go code.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL