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:
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
}
+