Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

zig-jsonschema

CI License: MIT

JSON Schema Draft 2020-12 validator in Zig. Single runtime dependency (mvzr for regex), zero allocations during validation, single-file implementation.

Tested against Zig 0.15.2 and 0.16.0.

Features

  • Full Draft 2020-12 keyword support (see Supported Keywords)
  • Format validation as assertionformat rejects invalid values (not annotation-only)
  • Punycode/IDNA2008 hostname validation — full RFC 3492 decoder, RFC 5891/5892 codepoint classification
  • Arena-friendly — schema parsing allocates, validation does not
  • Tested against the official JSON Schema Test Suite — 1190/1191 non-skipped tests passing (99.9%)

Usage from Zig

Add to your build.zig.zon:

.dependencies = .{
    .jsonschema = .{
        .url = "https://github.com/whiskeytuesday/zig-jsonschema/archive/refs/tags/v0.2.0.tar.gz",
        .hash = "...",
    },
},

In your build.zig, import the module:

const jsonschema = b.dependency("jsonschema", .{
    .target = target,
    .optimize = optimize,
});
your_module.addImport("jsonschema", jsonschema.module("jsonschema"));

Then use it:

const jsonschema = @import("jsonschema");

// Schema and data are std.json.Value (parsed from JSON)
const parsed_schema = try std.json.parseFromSlice(
    std.json.Value, allocator, schema_json_string, .{},
);
defer parsed_schema.deinit();

const schema = try jsonschema.parseSchema(allocator, parsed_schema.value);

const parsed_data = try std.json.parseFromSlice(
    std.json.Value, allocator, data_json_string, .{},
);
defer parsed_data.deinit();

var result = jsonschema.validate(allocator, parsed_data.value, schema);
defer result.deinit();

if (result.isValid()) {
    // data matches schema
} else {
    for (result.errors.items) |err| {
        std.debug.print("{s}: {s}\n", .{ err.instance_path, err.message });
    }
}

Usage from C

Build the static library:

zig build -Doptimize=ReleaseFast
# produces: zig-out/lib/libjsonschema.a
#           zig-out/include/jsonschema.h

Link against it and include the header:

#include "jsonschema.h"
#include <stdio.h>

int main(void) {
    // Parse a schema
    const char *schema_json =
        "{\"type\": \"object\","
        " \"properties\": {\"name\": {\"type\": \"string\", \"minLength\": 1}},"
        " \"required\": [\"name\"]}";

    jsonschema_schema *schema = jsonschema_parse(schema_json, strlen(schema_json));
    if (!schema) {
        fprintf(stderr, "schema parse failed\n");
        return 1;
    }

    // Validate data
    const char *good = "{\"name\": \"Alice\"}";
    jsonschema_result *r1 = jsonschema_validate(schema, good, strlen(good));
    printf("valid: %d\n", jsonschema_result_is_valid(r1));  // 1
    jsonschema_free_result(r1);

    // Invalid data
    const char *bad = "{\"name\": \"\"}";
    jsonschema_result *r2 = jsonschema_validate(schema, bad, strlen(bad));
    printf("valid: %d\n", jsonschema_result_is_valid(r2));  // 0

    size_t n = jsonschema_result_error_count(r2);
    for (size_t i = 0; i < n; i++) {
        size_t msg_len, path_len;
        const char *msg = jsonschema_result_error_message(r2, i, &msg_len);
        const char *path = jsonschema_result_error_path(r2, i, &path_len);
        printf("  error at %.*s: %.*s\n",
               (int)path_len, path, (int)msg_len, msg);
    }
    jsonschema_free_result(r2);

    jsonschema_free_schema(schema);
    return 0;
}

Compile and link:

cc -o validate example.c -Lzig-out/lib -ljsonschema -lc -lm

Supported Keywords

Type and Value

type, enum, const

Objects

properties, patternProperties, additionalProperties, required, propertyNames, minProperties, maxProperties, dependentRequired, dependentSchemas

Arrays

items, prefixItems, minItems, maxItems, uniqueItems, contains, minContains, maxContains

Strings

minLength, maxLength, pattern, format

Numbers

minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf

Composition

allOf, anyOf, oneOf, not

Conditional

if, then, else

References

$ref, $defs

Boolean Schemas

true (accept everything), false (reject everything)

Formats

uuid, email, date, date-time, time, uri, uri-reference, hostname, ipv4, ipv6, regex, json-pointer

Intentionally Unsupported

These keywords are recognized and rejected at parse time (error.UnsupportedKeyword), not silently ignored. This is a deliberate design choice — silent acceptance of keywords that alter validation semantics would give users false confidence that their schemas are fully enforced.

unevaluatedProperties / unevaluatedItems

What they do: Catch properties or array items that weren't "evaluated" by any subschema in a composition (allOf, anyOf, oneOf, if/then/else). This requires tracking which properties were touched by which branch of schema composition — annotation collection.

Why not supported: Annotation collection fundamentally changes the validation architecture. Every subschema evaluation must propagate metadata about which properties/items it examined, and the parent must aggregate this across all branches. This couples composition and object/array validation in a way that adds significant complexity for a feature that has limited real-world use. Most schemas that need to restrict extra properties can use additionalProperties at the leaf level.

Workaround: Use additionalProperties: false on each individual schema in your composition, listing all expected properties explicitly. This is more verbose but unambiguous.

$dynamicRef / $dynamicAnchor

What they do: Enable dynamic scoping for $ref resolution. A $dynamicRef resolves to the nearest $dynamicAnchor in the call stack of schema evaluation, rather than the lexical location. This is primarily used for recursive extension — allowing a base schema to define a recursive point that derived schemas can override.

Why not supported: Dynamic scoping requires maintaining a runtime stack of evaluation contexts, fundamentally different from the static resolution model used by $ref. The primary use case (extensible recursive schemas) is niche, and the mental model is notoriously difficult to reason about correctly. The JSON Schema specification itself notes this feature is for advanced use cases.

Workaround: Use $ref with $defs. For recursive schemas, define the full schema inline rather than relying on dynamic dispatch.

$anchor

What they do: Named anchors within a schema document, allowing $ref: "#my-anchor" instead of JSON Pointer paths like $ref: "#/$defs/foo".

Why not supported: $defs + $ref with JSON Pointer paths covers the same functionality. Named anchors add a second naming system that must be tracked and resolved alongside JSON Pointer paths, with no additional expressive power. They also interact with $dynamicAnchor (which I don't support), and their primary ergonomic benefit (shorter paths) doesn't justify the implementation complexity.

Workaround: Use $defs with $ref: "#/$defs/name".

$vocabulary

What they do: Declare which JSON Schema vocabularies a metaschema requires. Validators that don't support a required vocabulary must refuse to validate.

Why not supported: Vocabulary negotiation is a meta-level feature for validators that need to support arbitrary custom metaschemas. I implement a fixed set of Draft 2020-12 keywords. Supporting $vocabulary would require remote $schema fetching and vocabulary registry — infrastructure concerns orthogonal to validation.

Workaround: None needed — the supported keyword set is documented. If your schema uses only supported keywords, it will validate correctly.

Remote $ref

What it does: Fetch schemas from URLs ($ref: "https://example.com/schema.json").

Why not supported: Network I/O during schema parsing introduces latency, failure modes (DNS, timeouts, certificate errors), and security concerns (SSRF). It also breaks the property that schema parsing is a pure function of its input. All schemas must be self-contained or use local $defs.

Workaround: Inline referenced schemas into $defs and use local $ref: "#/$defs/name".

Dependencies

mvzr v0.3.8

mvzr ("Minimum Viable Zig Regex") is a compact bytecode regex engine (~2000 lines, no dependencies beyond std). It provides the regex support needed for the pattern keyword, patternProperties, and the regex format validator.

mvzr is zero-allocation — regex compilation and matching happen in fixed-size structs (64 operations, 8 character sets by default), with no heap allocation.

Security consideration: regex denial-of-service. mvzr uses backtracking, which means carefully crafted patterns can cause exponential matching time. Patterns like (a+)+$ matched against "aaaaaaaaaaaaaaaaab" will hang. This is a concern only if untrusted users can supply regex patterns — i.e., if user-supplied strings end up in pattern or patternProperties keys in your schemas.

If your schemas are authored by trusted developers (the typical case), this is not a concern. If you accept schemas from untrusted sources, you should either:

  • Validate/reject regex patterns before schema parsing (e.g., reject patterns with nested quantifiers)
  • Set timeouts around schema validation calls
  • Strip pattern and patternProperties from untrusted schemas before parsing

The pattern keyword validates data against a schema-defined pattern. The data string being matched cannot cause exponential blowup — only the pattern itself can. So even with untrusted data, trusted schemas are safe.

Annotation-Only Keywords

These keywords are accepted in schemas but have no validation effect. They are metadata for documentation and tooling:

  • contentEncoding — content encoding hint (e.g., "base64")
  • contentMediaType — media type hint (e.g., "application/json")
  • contentSchema — schema for decoded content

Per the JSON Schema spec, these are informational only and do not affect validation.

Test Suite Compliance

Tested against the official JSON Schema Test Suite for Draft 2020-12.

Metric Count
Test files run 44 of 46 keyword files + 12 format files
Tests passed 1190
Tests failed 1
Tests skipped 305 (unsupported keywords/refs)
Pass rate 99.9% of non-skipped tests

Excluded Test Files

File Reason
format.json Tests annotation-only semantics (everything passes). I use optional/format/*.json which tests assertion semantics — the behavior users expect.
refRemote.json Tests remote $ref via URL. Not supported by design (see above).

Known Failure

One test in vocabulary.json fails: a custom metaschema with no validation vocabulary expects all inputs to pass. This requires remote $schema resolution to detect that validation is disabled, which I don't support.

Prior Art

Two other Zig JSON Schema validators exist:

  • DrDeano/jsonschema — Targets Draft 7 with partial keyword coverage (missing contains, if/then/else, $ref, format, uniqueItems, and others). Appears dormant since December 2022.
  • pascalPost/json-schema-validator — Also targets Draft 7 with broader keyword support but incomplete $ref and definitions handling.

This project exists because I needed Draft 2020-12, which is the current standard and the schema language underlying OpenAPI 3.1. Neither existing project targets a post-Draft-7 spec.

About

JSON Schema Draft 2020-12 validator in Zig

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages