odin-jsonschema

Implementation of JSON schema for Odin
Log | Files | Refs | LICENSE

commit a3c881c467613b5b8d0fcc8e0ad124a18ea8c62c
parent 2bea245868e882b5a234d346764051b4eb925494
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Thu, 23 Jul 2026 09:21:56 -0300

pkg/jschema: natively support openapi idioms

Support the thing-or-reference and if-then-else idioms used by openapi
schemas.

Context: openapi layers on idioms that aren't expressed in the schema
itself. To generate such documents in a more useful way (rather than
just punting to json.Value), we need to add logic for handling those
idioms.

This feature is optional.

Diffstat:
Msrc/pkg/jschema/emit.odin | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/pkg/jschema/generate_test.odin | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/pkg/jschema/jschema.odin | 23+++++++++++++++++++----
Msrc/pkg/jschema/pool.odin | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
4 files changed, 311 insertions(+), 29 deletions(-)

diff --git a/src/pkg/jschema/emit.odin b/src/pkg/jschema/emit.odin @@ -35,6 +35,7 @@ Member :: struct { Emitter :: struct { pool: ^Pool, opts: Options, + openapi: bool, // OpenAPI-idiom lowering enabled def_names: map[Node_Index]string, // raw def key per node names: []string, // assigned declaration name per node visit: []Visit_State, @@ -42,8 +43,9 @@ Emitter :: struct { queue: [dynamic]Node_Index, broken: map[u32]bool, // props that would form a by-value cycle taken: map[string]bool, - body: strings.Builder, - uses_json: bool, + body: strings.Builder, + uses_json: bool, + uses_or_reference: bool, } // Follows chains of nodes that are nothing but a $ref. @@ -140,6 +142,46 @@ enum_values :: proc(e: ^Emitter, idx: Node_Index) -> []string { } @(private) +is_reference_def :: proc(e: ^Emitter, idx: Node_Index) -> bool { + name, ok := e.def_names[idx] + return ok && name == "reference" +} + +// A 2-variant union shaped like OpenAPI's "X-or-reference" idiom: one +// branch resolves to the "reference" $def, the other to a concrete type. +// Lowered to the generic Or_Reference($T) instead of a dedicated decl. +// Only applies when the non-reference branch is itself a named decl +// (struct/enum/union); inline-only kinds (map, array, etc.) cannot be a +// polymorphic type argument and fall back to a dedicated union decl. +@(private) +is_or_reference :: proc(e: ^Emitter, idx: Node_Index) -> (ref: Node_Index, other: Node_Index, ok: bool) { + if !e.openapi { + return NIL_NODE, NIL_NODE, false + } + variants := union_variants(e, idx) + if len(variants) != 2 { + return NIL_NODE, NIL_NODE, false + } + a := effective(e, variants[0]) + b := effective(e, variants[1]) + a_ref := is_reference_def(e, a) + b_ref := is_reference_def(e, b) + if a_ref && !b_ref { + #partial switch classify(e, b) { + case .Struct, .Enum, .Union: + return a, b, true + } + } + if b_ref && !a_ref { + #partial switch classify(e, a) { + case .Struct, .Enum, .Union: + return b, a, true + } + } + return NIL_NODE, NIL_NODE, false +} + +@(private) union_variants :: proc(e: ^Emitter, idx: Node_Index) -> []Node_Index { node := &e.pool.nodes[idx] r := node.one_of if node.one_of.count > 0 else node.any_of @@ -356,6 +398,12 @@ type_expr :: proc(e: ^Emitter, idx: Node_Index, hint: string) -> (expr: string, case .Struct, .Enum: return ensure_decl(e, target, hint), false, nullable case .Union: + if ref, other, ok := is_or_reference(e, target); ok { + other_name := ensure_decl(e, other, hint) + ensure_decl(e, ref, "Reference") + e.uses_or_reference = true + return strings.concatenate({"Or_Reference(", other_name, ")"}), true, nullable + } if _, is_def := e.def_names[target]; is_def || e.names[target] != "" { return ensure_decl(e, target, hint), true, nullable } @@ -553,8 +601,18 @@ emit_enum :: proc(e: ^Emitter, idx: Node_Index) { @(private) emit_union :: proc(e: ^Emitter, idx: Node_Index) { name := e.names[idx] - expr, _, _ := union_expr(e, idx, name) b := &e.body + if ref, other, ok := is_or_reference(e, idx); ok { + other_name := ensure_decl(e, other, name) + ensure_decl(e, ref, "Reference") + e.uses_or_reference = true + strings.write_string(b, name) + strings.write_string(b, " :: Or_Reference(") + strings.write_string(b, other_name) + strings.write_string(b, ")") + return + } + expr, _, _ := union_expr(e, idx, name) strings.write_string(b, name) strings.write_string(b, " :: ") strings.write_string(b, expr) @@ -562,13 +620,14 @@ emit_union :: proc(e: ^Emitter, idx: Node_Index) { // Emits the full Odin source for a resolved pool. @(private) -emit :: proc(pool: ^Pool, opts: Options) -> string { +emit :: proc(pool: ^Pool, opts: Options, openapi: bool) -> string { e := Emitter { - pool = pool, - opts = opts, - names = make([]string, len(pool.nodes)), - visit = make([]Visit_State, len(pool.nodes)), - queued = make([]bool, len(pool.nodes)), + pool = pool, + opts = opts, + openapi = openapi, + names = make([]string, len(pool.nodes)), + visit = make([]Visit_State, len(pool.nodes)), + queued = make([]bool, len(pool.nodes)), } strings.builder_init(&e.body) @@ -619,6 +678,9 @@ emit :: proc(pool: ^Pool, opts: Options) -> string { if e.uses_json { strings.write_string(&out, "import \"core:encoding/json\"\n\n") } + if e.uses_or_reference { + strings.write_string(&out, "Or_Reference :: union($T: typeid) {Reference, T}\n\n") + } strings.write_string(&out, strings.to_string(e.body)) strings.write_string(&out, "\n") return strings.to_string(out) diff --git a/src/pkg/jschema/generate_test.odin b/src/pkg/jschema/generate_test.odin @@ -84,6 +84,73 @@ generates_openapi :: proc(t: ^testing.T) { testing.expect(t, strings.contains(got, "Root :: struct"), "missing root struct") testing.expect(t, strings.contains(got, "Info :: struct"), "missing Info struct") testing.expect(t, len(got) > 2000, "suspiciously small output for a 1500-line schema") + // Auto-detected OpenAPI mode: if/then/else lowering and Or_Reference + // generic union should be present. + testing.expect(t, strings.contains(got, "Or_Reference :: union"), "missing Or_Reference generic") + testing.expect(t, strings.contains(got, "Or_Reference(Parameter)"), "missing Or_Reference(Parameter)") + testing.expect(t, !strings.contains(got, "Parameter_Or_Reference"), "should use Or_Reference, not dedicated decl") +} + +// A schema with if/then/else but no "openapi" property: auto-detection +// leaves it off, so the conditional is ignored (value -> json.Value). +@(test) +openapi_flag_forces_on :: proc(t: ^testing.T) { + schema := transmute([]u8)string(`{ + "type": "object", + "properties": { + "value": { + "if": {"type": "object", "required": ["$ref"]}, + "then": {"$ref": "#/$defs/a"}, + "else": {"$ref": "#/$defs/b"} + } + }, + "$defs": { + "a": {"type": "object", "properties": {"a": {"type": "string"}}}, + "b": {"type": "object", "properties": {"b": {"type": "integer"}}} + } + }`) + // Without the flag, auto-detect sees no "openapi" property -> raw mode. + got, gerr := generate_source(schema, allocator = context.temp_allocator) + if !testing.expectf(t, gerr == nil, "generate_source failed: %v", gerr) { + return + } + testing.expect(t, strings.contains(got, "json.Value"), "raw mode should degrade if/then/else to json.Value") + testing.expect(t, !strings.contains(got, "union {A, B}"), "raw mode should not lower if/then/else") + + // With -openapi forced on, if/then/else lowers to a union. + got_on, on_err := generate_source(schema, opts = {openapi = true}, allocator = context.temp_allocator) + if !testing.expectf(t, on_err == nil, "generate_source failed: %v", on_err) { + return + } + testing.expect(t, strings.contains(got_on, "union {A, B}"), "openapi mode should lower if/then/else to a union") +} + +// -openapi:false forces raw mode even when the document has an "openapi" +// property (auto-detect would otherwise enable idioms). +@(test) +openapi_flag_forces_off :: proc(t: ^testing.T) { + schema := transmute([]u8)string(`{ + "type": "object", + "properties": { + "openapi": {"type": "string"}, + "value": { + "if": {"type": "object", "required": ["$ref"]}, + "then": {"$ref": "#/$defs/a"}, + "else": {"$ref": "#/$defs/b"} + } + }, + "$defs": { + "a": {"type": "object", "properties": {"a": {"type": "string"}}}, + "b": {"type": "object", "properties": {"b": {"type": "integer"}}} + } + }`) + got, gerr := generate_source(schema, opts = {openapi = false}, allocator = context.temp_allocator) + if !testing.expectf(t, gerr == nil, "generate_source failed: %v", gerr) { + return + } + testing.expect(t, strings.contains(got, "json.Value"), "forced-off should degrade if/then/else to json.Value") + testing.expect(t, !strings.contains(got, "Or_Reference"), "forced-off should not emit Or_Reference") + testing.expect(t, !strings.contains(got, "union {A, B}"), "forced-off should not lower if/then/else") } @(test) diff --git a/src/pkg/jschema/jschema.odin b/src/pkg/jschema/jschema.odin @@ -11,8 +11,9 @@ import "core:path/filepath" import "core:strings" Options :: struct { - package_name: string, // package of the generated file; default "schema" - root_name: string, // name of the root declaration; default "Root" + package_name: string, // package of the generated file; default "schema" + root_name: string, // name of the root declaration; default "Root" + openapi: Maybe(bool), // nil = auto-detect via "openapi" property } Error :: union { @@ -99,13 +100,27 @@ generate_in_arena :: proc( pool: Pool b := Builder { - pool = &pool, + pool = &pool, + openapi = resolve_openapi(opts, data), } dir := base_dir if base_dir != "" else "." pool.root = parse_document(&b, data, "<schema>", "", dir) or_return resolve_refs(&b) or_return - return emit(&pool, opts), nil + return emit(&pool, opts, b.openapi), nil +} + +// Resolves the OpenAPI-idiom flag: an explicit value in opts takes +// priority; otherwise the document is shallow-scanned for a top-level +// "openapi" property (the OpenAPI version marker). +@(private) +resolve_openapi :: proc(opts: Options, data: []u8) -> bool { + switch v in opts.openapi { + case bool: + return v + case: + return detect_openapi(data) + } } // Error strings can point into the internal arena; clone them so the error diff --git a/src/pkg/jschema/pool.odin b/src/pkg/jschema/pool.odin @@ -32,7 +32,7 @@ Range :: struct { Enum_Kind :: enum u8 { None, Strings, // every value is a string - Mixed, // at least one non-string value; falls back to the base type + Mixed, // at least one non-string value; falls back to the base type } Bool3 :: enum u8 { @@ -48,12 +48,12 @@ Node :: struct { ref: Node_Index, // resolved $ref target items: Node_Index, additional: Node_Index, // additionalProperties / sole patternProperties schema - props: Range, // into Pool.props - required: Range, // into Pool.names - enum_values: Range, // into Pool.strings (string values only) - all_of: Range, // into Pool.children - any_of: Range, // into Pool.children - one_of: Range, // into Pool.children + props: Range, // into Pool.props + required: Range, // into Pool.names + enum_values: Range, // into Pool.strings (string values only) + all_of: Range, // into Pool.children + any_of: Range, // into Pool.children + one_of: Range, // into Pool.children } Prop :: struct { @@ -81,14 +81,15 @@ Pool :: struct { @(private) Pending_Ref :: struct { node: Node_Index, // node carrying the $ref - ref: string, // the $ref string as written - file: string, // pointer-map key prefix of the file the ref appears in - dir: string, // directory for resolving relative file refs + ref: string, // the $ref string as written + file: string, // pointer-map key prefix of the file the ref appears in + dir: string, // directory for resolving relative file refs } @(private) Builder :: struct { pool: ^Pool, + openapi: bool, // enables if/then/else lowering and or-reference idioms // "<file key>#<json pointer>" -> node, for $ref resolution. pointers: map[string]Node_Index, // canonical file path -> root node, so each file is parsed once. @@ -196,6 +197,8 @@ parse_schema :: proc(b: ^Builder, p: ^json.Parser, ptr: string) -> (idx: Node_In } json.advance_token(p) + then_node: Node_Index = NIL_NODE + else_node: Node_Index = NIL_NODE for p.curr_token.kind != .Close_Brace { key, kerr := json.parse_object_key(p, context.allocator) if kerr != nil { @@ -229,7 +232,11 @@ parse_schema :: proc(b: ^Builder, p: ^json.Parser, ptr: string) -> (idx: Node_In b.pool.nodes[idx].additional_bool = .False json.advance_token(p) case: - additional := parse_schema(b, p, pointer_append(ptr, "additionalProperties")) or_return + additional := parse_schema( + b, + p, + pointer_append(ptr, "additionalProperties"), + ) or_return b.pool.nodes[idx].additional = additional } case "patternProperties": @@ -241,14 +248,46 @@ parse_schema :: proc(b: ^Builder, p: ^json.Parser, ptr: string) -> (idx: Node_In case "$ref": ref := parse_string_value(b, p) or_return append(&b.pending, Pending_Ref{node = idx, ref = ref, file = b.file_key, dir = b.dir}) + case "if": + // The condition cannot be evaluated at codegen time; when + // openapi mode is on, the then/else branches are lowered to + // an implicit union below. In raw JSON Schema mode if/then/else + // is a validation keyword with no static type effect. + if jerr := skip_value(p); jerr != nil { + return idx, parse_error(b, p, "malformed JSON value") + } + case "then": + if b.openapi { + then_node = parse_schema(b, p, pointer_append(ptr, "then")) or_return + } else if jerr := skip_value(p); jerr != nil { + return idx, parse_error(b, p, "malformed JSON value") + } + case "else": + if b.openapi { + else_node = parse_schema(b, p, pointer_append(ptr, "else")) or_return + } else if jerr := skip_value(p); jerr != nil { + return idx, parse_error(b, p, "malformed JSON value") + } case "$defs", "definitions": parse_defs(b, p, pointer_append(ptr, key)) or_return case "allOf": - b.pool.nodes[idx].all_of = parse_schema_list(b, p, pointer_append(ptr, "allOf")) or_return + b.pool.nodes[idx].all_of = parse_schema_list( + b, + p, + pointer_append(ptr, "allOf"), + ) or_return case "anyOf": - b.pool.nodes[idx].any_of = parse_schema_list(b, p, pointer_append(ptr, "anyOf")) or_return + b.pool.nodes[idx].any_of = parse_schema_list( + b, + p, + pointer_append(ptr, "anyOf"), + ) or_return case "oneOf": - b.pool.nodes[idx].one_of = parse_schema_list(b, p, pointer_append(ptr, "oneOf")) or_return + b.pool.nodes[idx].one_of = parse_schema_list( + b, + p, + pointer_append(ptr, "oneOf"), + ) or_return case: if jerr := skip_value(p); jerr != nil { return idx, parse_error(b, p, "malformed JSON value") @@ -262,6 +301,26 @@ parse_schema :: proc(b: ^Builder, p: ^json.Parser, ptr: string) -> (idx: Node_In if jerr := json.expect_token(p, .Close_Brace); jerr != nil { return idx, parse_error(b, p, "expected '}' to close schema object") } + // Lower if/then/else into an implicit anyOf of the then and else + // branches (ignoring the if condition, which is a runtime guard). + // Only active in openapi mode; in raw mode then_node/else_node are NIL. + if b.openapi && (then_node != NIL_NODE || else_node != NIL_NODE) { + parts := make([dynamic]Node_Index, context.temp_allocator) + existing := b.pool.nodes[idx].any_of + if existing.count > 0 { + append(&parts, ..b.pool.children[existing.first:][:existing.count]) + } + if then_node != NIL_NODE { + append(&parts, then_node) + } + if else_node != NIL_NODE { + append(&parts, else_node) + } + if len(parts) > 0 { + b.pool.nodes[idx].any_of = Range{u32(len(b.pool.children)), u32(len(parts))} + append(&b.pool.children, ..parts[:]) + } + } return idx, nil } @@ -410,7 +469,12 @@ parse_const :: proc(b: ^Builder, p: ^json.Parser, idx: Node_Index) -> Error { // A single-pattern patternProperties object is treated like // additionalProperties; anything richer degrades to an untyped map. @(private) -parse_pattern_properties :: proc(b: ^Builder, p: ^json.Parser, idx: Node_Index, ptr: string) -> Error { +parse_pattern_properties :: proc( + b: ^Builder, + p: ^json.Parser, + idx: Node_Index, + ptr: string, +) -> Error { if jerr := json.expect_token(p, .Open_Brace); jerr != nil { return parse_error(b, p, "expected an object for \"patternProperties\"") } @@ -511,9 +575,82 @@ itoa :: proc(n: int) -> string { return strings.clone(string(buf[i:])) } +// Shallow-scans a schema document for signals that it uses OpenAPI +// idioms (if/then/else "X-or-reference" patterns). Two shapes trigger +// detection: +// +// 1. An OpenAPI *instance* document: a root-level "openapi" key (the +// version marker, e.g. {"openapi": "3.2.0", ...}). +// 2. The OpenAPI *meta-schema*: a root-level "properties" object that +// itself contains an "openapi" key (describing field 1). +// +detect_openapi :: proc(data: []u8) -> bool { + p := json.make_parser(data, .JSON, true, context.temp_allocator) + if p.curr_token.kind != .Open_Brace { + return false + } + json.advance_token(&p) + for p.curr_token.kind != .Close_Brace { + key, kerr := json.parse_object_key(&p, context.temp_allocator) + if kerr != nil { + return false + } + if jerr := json.parse_colon(&p); jerr != nil { + return false + } + // Case 1: root-level "openapi" key (instance document). + if key == "openapi" { + return true + } + // Case 2: "properties" containing an "openapi" sub-key (meta-schema). + if key == "properties" && p.curr_token.kind == .Open_Brace { + json.advance_token(&p) + for p.curr_token.kind != .Close_Brace { + prop, perr := json.parse_object_key(&p, context.temp_allocator) + if perr != nil { + return false + } + if jerr := json.parse_colon(&p); jerr != nil { + return false + } + if prop == "openapi" { + return true + } + if jerr := skip_value(&p); jerr != nil { + return false + } + if json.parse_comma(&p) { + break + } + } + // "properties" with no "openapi" sub-key; keep scanning the root. + if json.parse_comma(&p) { + break + } + continue + } + if jerr := skip_value(&p); jerr != nil { + return false + } + if json.parse_comma(&p) { + break + } + } + return false +} + // Parses one schema document into the pool, returning its root node. @(private) -parse_document :: proc(b: ^Builder, data: []u8, path: string, file_key: string, dir: string) -> (root: Node_Index, err: Error) { +parse_document :: proc( + b: ^Builder, + data: []u8, + path: string, + file_key: string, + dir: string, +) -> ( + root: Node_Index, + err: Error, +) { prev_path, prev_key, prev_dir := b.path, b.file_key, b.dir b.path, b.file_key, b.dir = path, file_key, dir defer b.path, b.file_key, b.dir = prev_path, prev_key, prev_dir @@ -525,3 +662,4 @@ parse_document :: proc(b: ^Builder, data: []u8, path: string, file_key: string, } return root, nil } +