commit 2bea245868e882b5a234d346764051b4eb925494 Author: Jack Mordaunt <jackmordaunt.dev@gmail.com> Date: Tue, 21 Jul 2026 09:22:50 -0300 init: json schema implementation Implement JSON schema for Odin. cmd/jschema consumes JSON representing a data schema and outputs Odin types. Diffstat:
74 files changed, 4429 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore @@ -0,0 +1,2 @@ +build/ +.DS_Store diff --git a/.ignore b/.ignore @@ -0,0 +1 @@ +# testdata diff --git a/LICENSE b/LICENSE @@ -0,0 +1,63 @@ +This project is provided under the terms of the UNLICENSE or +the MIT license denoted by the following SPDX identifier: + +SPDX-License-Identifier: Unlicense OR MIT + +You may use the project under the terms of either license. + +Both licenses are reproduced below. + +---- +The MIT License (MIT) + +Copyright (c) 2026 Jack Mordaunt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +--- + + + +--- +The UNLICENSE + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to <https://unlicense.org/> +--- diff --git a/Makefile b/Makefile @@ -0,0 +1,23 @@ +ODIN ?= odin + +.PHONY: build test e2e bench check clean + +build: + mkdir -p build + $(ODIN) build src/cmd/jschema -out:build/jschema -o:speed + +test: + $(ODIN) test src/pkg/jschema -out:build/jschema_test + +e2e: + ODIN=$(ODIN) scripts/e2e.sh + +bench: + mkdir -p build + $(ODIN) build src/cmd/bench -out:build/bench -o:speed + build/bench testdata/cases/openapi/schema.json + +check: test e2e + +clean: + rm -rf build diff --git a/README.MD b/README.MD @@ -0,0 +1,91 @@ +# odin-jsonschema + +Generates Odin type declarations from JSON Schema documents (draft-07 and +2020-12). The generated types parse with `core:encoding/json` — no runtime +library required. + +## Usage + +### CLI + +```sh +make build # -> build/jschema + +build/jschema schema.json -o:types.odin # file to file +cat schema.json | build/jschema # stdin to stdout +build/jschema schema.json -pkg:api -root:Document +``` + +Flags: + +| flag | meaning | default | +|------|---------|---------| +| `-o:<path>` | output file | stdout | +| `-pkg:<name>` | package name of the generated file | `schema` | +| `-root:<name>` | name of the root declaration | `Root` | + +Debug builds (`odin build src/cmd/jschema -debug`) wrap the allocator in +`mem.Tracking_Allocator` and report leaks and bad frees on exit. + +### Library + +```odin +import "src/pkg/jschema" + +err := jschema.generate("schema.json", "types.odin") +``` + +`generate_source` is the in-memory variant used for stdin/pipelines. All +intermediate state lives in an internal arena that is freed before returning; +the only caller-facing allocation is the returned source string. + +## Type mapping + +| schema | Odin | +|--------|------| +| `string` / `integer` / `number` / `boolean` | `string` / `i64` / `f64` / `bool` | +| `object` with `properties` (or `allOf`) | named `struct`, merged across `allOf` and `$ref` | +| `object` with `additionalProperties` / single-pattern `patternProperties` | `map[string]T` | +| `array` | `[]T` | +| optional or nullable field | `Maybe(T)` | +| `enum` of strings that are valid Odin identifiers | named `enum` (variant names match the JSON strings exactly, as required by `core:encoding/json`) | +| `enum` otherwise | `string` | +| `oneOf` / `anyOf` | `union {..}` (a `null` variant folds into `Maybe`/nil) | +| `type: [..]` with several types | `union {..}` | +| no usable constraints | `json.Value` | +| `$ref` | named type; local pointers (`#/$defs/..`, `#/definitions/..`) and relative file refs are resolved | + +Recursive schemas work through slices and maps. A schema that contains itself +*by value* (illegal in Odin, and `core:encoding/json` cannot unmarshal pointer +fields) has the offending property emitted as `json.Value`. + +Known limitations: + +- `core:encoding/json` tries union variants in declaration order and skips + unknown object keys, so for `oneOf` of similar objects the first variant + that parses wins. +- Tuple-form `items` / `prefixItems` degrade to `[]json.Value`. +- `$anchor` and remote (URL) refs are not supported; unresolvable refs are an + error. + +## Internals + +The schema is stored data-oriented: one flat pool of fixed-size nodes plus +shared side arrays (`props`, `children`, `names`, `strings`), all cross-linked +by `u32` indices. Documents are lowered straight from the JSON tokenizer into +the pool — no intermediate `json.Value` tree — which preserves declaration +order and keeps output deterministic. + +## Testing + +```sh +make test # unit + golden tests (odin test src/pkg/jschema) +make e2e # generate -> compile -> parse sample.json per testdata case +make bench # performance report (time, throughput, allocations, leaks) +make check # test + e2e +``` + +Each directory under `testdata/cases/` holds a `schema.json`, the expected +generated code (`expected.odin`), a `sample.json` instance, and a +`check.odin` test that asserts the parsed values. The `openapi` case runs the +full OpenAPI 3.2 document schema end-to-end. diff --git a/scripts/e2e.sh b/scripts/e2e.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# End-to-end test: for every testdata case with a check.odin, generate Odin +# types from its schema, compile them together with the hand-written checks, +# and run the checks against sample.json. +set -euo pipefail + +ODIN=${ODIN:-odin} +root=$(cd "$(dirname "$0")/.." && pwd) +build="$root/build/e2e" + +"$ODIN" build "$root/src/cmd/jschema" -debug -out:"$build/jschema" 2>/dev/null || + { mkdir -p "$build" && "$ODIN" build "$root/src/cmd/jschema" -debug -out:"$build/jschema"; } + +failures=0 +for case_dir in "$root"/testdata/cases/*/; do + name=$(basename "$case_dir") + [ -f "$case_dir/check.odin" ] || continue + + dir="$build/$name" + mkdir -p "$dir" + rm -f "$dir"/*.odin "$dir"/*.json + + "$build/jschema" "$case_dir/schema.json" -pkg:check -o:"$dir/generated.odin" + cp "$case_dir/check.odin" "$dir/" + cp "$case_dir/sample.json" "$dir/" + + if "$ODIN" test "$dir" -out:"$dir/check_test" >"$dir/test.log" 2>&1; then + echo "ok $name" + else + echo "FAIL $name" + cat "$dir/test.log" + failures=$((failures + 1)) + fi +done + +if [ "$failures" -gt 0 ]; then + echo "e2e: $failures case(s) failed" + exit 1 +fi +echo "e2e: all cases passed" diff --git a/src/cmd/bench/main.odin b/src/cmd/bench/main.odin @@ -0,0 +1,114 @@ +// bench measures jschema generation performance: wall time per iteration, +// throughput, and allocation behaviour for a given schema file. +// +// Usage: bench [schema.json] [iterations] +package main + +import "core:fmt" +import "core:mem" +import "core:os" +import "core:path/filepath" +import "core:strconv" +import "core:time" + +import "../../pkg/jschema" + +DEFAULT_ITERATIONS :: 200 + +main :: proc() { + os.exit(run()) +} + +run :: proc() -> int { + schema_path := "testdata/cases/openapi/schema.json" + iterations := DEFAULT_ITERATIONS + args := os.args[1:] + if len(args) > 0 { + schema_path = args[0] + } + if len(args) > 1 { + parsed, ok := strconv.parse_int(args[1]) + if !ok || parsed <= 0 { + fmt.eprintfln("bench: invalid iteration count %q", args[1]) + return 2 + } + iterations = parsed + } + + data, rerr := os.read_entire_file_from_path(schema_path, context.allocator) + if rerr != nil { + fmt.eprintfln("bench: cannot read %s: %v", schema_path, rerr) + return 1 + } + defer delete(data) + base_dir := filepath.dir(schema_path) + + // Warmup, and fail fast on a broken schema. + for _ in 0 ..< 3 { + source, err := jschema.generate_source(data, base_dir = base_dir) + if err != nil { + fmt.eprintfln("bench: generate failed: %v", err) + return 1 + } + delete(source) + } + + total: time.Duration + fastest := time.Duration(max(i64)) + slowest := time.Duration(0) + output_size := 0 + for _ in 0 ..< iterations { + start := time.tick_now() + source, _ := jschema.generate_source(data, base_dir = base_dir) + elapsed := time.tick_since(start) + output_size = len(source) + delete(source) + + total += elapsed + fastest = min(fastest, elapsed) + slowest = max(slowest, elapsed) + } + average := total / time.Duration(iterations) + + // One tracked run for the allocation report. The tracking allocator only + // sees caller-facing allocations plus the arena's block requests, which is + // exactly the library's real footprint on the caller's allocator. + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + { + context.allocator = mem.tracking_allocator(&track) + source, _ := jschema.generate_source(data, base_dir = base_dir) + delete(source) + } + leaks := len(track.allocation_map) + total_allocated := track.total_memory_allocated + peak := track.peak_memory_allocated + allocation_count := track.total_allocation_count + bad_frees := len(track.bad_free_array) + mem.tracking_allocator_destroy(&track) + + seconds := time.duration_seconds(average) + throughput := f64(len(data)) / seconds / mem.Megabyte + + fmt.printfln( + "schema: %s (%d bytes in, %d bytes out)", + schema_path, + len(data), + output_size, + ) + fmt.printfln("iterations: %d", iterations) + fmt.printfln("time/op: avg %v min %v max %v", average, fastest, slowest) + fmt.printfln("throughput: %.1f MiB/s", throughput) + fmt.printfln( + "allocations: %d calls, %m total, %m peak", + allocation_count, + total_allocated, + peak, + ) + fmt.printfln("leaks: %d, bad frees: %d", leaks, bad_frees) + if leaks > 0 || bad_frees > 0 { + return 1 + } + return 0 +} + diff --git a/src/cmd/jschema/main.odin b/src/cmd/jschema/main.odin @@ -0,0 +1,136 @@ +// jschema generates Odin type declarations from a JSON Schema document. +// +// Usage: +// jschema schema.json -o:types.odin generate from a file +// cat schema.json | jschema generate from stdin to stdout +// +// Flags: +// -o:<path> output file (default: stdout) +// -pkg:<name> package name of the generated file (default: schema) +// -root:<name> name of the root declaration (default: Root) +package main + +import "core:fmt" +import "core:mem" +import "core:os" +import "core:path/filepath" +import "core:strings" + +import "../../pkg/jschema" + +main :: proc() { + when ODIN_DEBUG { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + context.allocator = mem.tracking_allocator(&track) + defer { + for _, entry in track.allocation_map { + fmt.eprintfln("jschema: leaked %v bytes at %v", entry.size, entry.location) + } + for entry in track.bad_free_array { + fmt.eprintfln("jschema: bad free at %v", entry.location) + } + mem.tracking_allocator_destroy(&track) + } + } + + os.exit(run()) +} + +run :: proc() -> int { + input := "" + output := "" + opts := jschema.Options{} + + for arg in os.args[1:] { + switch { + case arg == "-h" || arg == "--help": + fmt.println(USAGE) + return 0 + case strings.has_prefix(arg, "-o:"): + output = arg[len("-o:"):] + case strings.has_prefix(arg, "-pkg:"): + opts.package_name = arg[len("-pkg:"):] + case strings.has_prefix(arg, "-root:"): + opts.root_name = arg[len("-root:"):] + case strings.has_prefix(arg, "-"): + fmt.eprintfln("jschema: unknown flag %q\n%s", arg, USAGE) + return 2 + case input != "": + fmt.eprintfln("jschema: multiple schema paths given\n%s", USAGE) + return 2 + case: + input = arg + } + } + + if input != "" && output != "" { + if err := jschema.generate(input, output, opts); err != nil { + report(err) + return 1 + } + return 0 + } + + data: []u8 + base_dir := "" + if input == "" { + stdin_data, rerr := os.read_entire_file_from_file(os.stdin, context.allocator) + if rerr != nil { + fmt.eprintfln("jschema: cannot read stdin: %v", rerr) + return 1 + } + data = stdin_data + } else { + file_data, rerr := os.read_entire_file_from_path(input, context.allocator) + if rerr != nil { + fmt.eprintfln("jschema: cannot read %s: %v", input, rerr) + return 1 + } + data = file_data + base_dir = filepath.dir(input) // substring of input; not freed + } + defer delete(data) + + source, err := jschema.generate_source(data, opts, base_dir) + if err != nil { + report(err) + return 1 + } + defer delete(source) + + if output == "" { + os.write(os.stdout, transmute([]u8)source) + return 0 + } + if werr := os.write_entire_file(output, source); werr != nil { + fmt.eprintfln("jschema: cannot write %s: %v", output, werr) + return 1 + } + return 0 +} + +report :: proc(err: jschema.Error) { + switch specific in err { + case jschema.IO_Error: + fmt.eprintfln("jschema: cannot access %s: %v", specific.path, specific.error) + case jschema.Parse_Error: + fmt.eprintfln( + "jschema: %s:%d:%d: %s", + specific.path, + specific.pos.line, + specific.pos.column, + specific.message, + ) + case jschema.Resolve_Error: + fmt.eprintfln("jschema: %s: cannot resolve $ref %q", specific.path, specific.ref) + case mem.Allocator_Error: + fmt.eprintfln("jschema: out of memory: %v", specific) + } +} + +USAGE :: `usage: jschema [schema.json] [-o:output.odin] [-pkg:name] [-root:name] + +Generates Odin type declarations from a JSON Schema (draft-07 or 2020-12). +Reads from stdin when no schema path is given; writes to stdout when -o is +not given.` diff --git a/src/pkg/jschema/emit.odin b/src/pkg/jschema/emit.odin @@ -0,0 +1,625 @@ +package jschema + +import "core:strings" + +@(private) +Kind :: enum u8 { + Any, // no usable constraints -> json.Value + Boolean, + Integer, + Number, + Str, + Struct, + Enum, + Union, // oneOf / anyOf + Array, + Map, // object keyed by arbitrary strings + Multi, // several simple types -> inline union +} + +@(private) +Visit_State :: enum u8 { + Unvisited, + On_Stack, + Done, +} + +@(private) +Member :: struct { + prop: u32, // global index into Pool.props + name: string, // original JSON name + node: Node_Index, +} + +@(private) +Emitter :: struct { + pool: ^Pool, + opts: Options, + def_names: map[Node_Index]string, // raw def key per node + names: []string, // assigned declaration name per node + visit: []Visit_State, + queued: []bool, + 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, +} + +// Follows chains of nodes that are nothing but a $ref. +@(private) +effective :: proc(e: ^Emitter, idx: Node_Index) -> Node_Index { + idx := idx + for _ in 0 ..< len(e.pool.nodes) { + node := &e.pool.nodes[idx] + if node.ref == NIL_NODE { + break + } + pure := node.props.count == 0 && + node.all_of.count == 0 && + node.any_of.count == 0 && + node.one_of.count == 0 && + node.enum_kind == .None && + node.types == {} && + node.items == NIL_NODE && + node.additional == NIL_NODE + if !pure { + break + } + idx = node.ref + } + return idx +} + +@(private) +is_null_only :: proc(e: ^Emitter, idx: Node_Index) -> bool { + node := &e.pool.nodes[idx] + return node.types == {.Null} && + node.props.count == 0 && + node.all_of.count == 0 && + node.any_of.count == 0 && + node.one_of.count == 0 && + node.enum_kind == .None +} + +@(private) +classify :: proc(e: ^Emitter, idx: Node_Index) -> Kind { + node := &e.pool.nodes[idx] + if node.props.count > 0 || node.all_of.count > 0 { + return .Struct + } + if node.enum_kind == .Strings { + all_idents := node.enum_values.count > 0 + for value in enum_values(e, idx) { + if !is_valid_ident(value) { + all_idents = false + break + } + } + if all_idents { + return .Enum + } + return .Str + } + if node.one_of.count > 0 || node.any_of.count > 0 { + return .Union + } + + types := node.types - {.Null} + switch card(types) { + case 0: + if node.additional != NIL_NODE || node.additional_bool != .Unset { + return .Map + } + if node.items != NIL_NODE { + return .Array + } + return .Any + case 1: + switch { + case .String in types: + return .Str + case .Integer in types: + return .Integer + case .Number in types: + return .Number + case .Boolean in types: + return .Boolean + case .Array in types: + return .Array + } + return .Map // .Object + } + return .Multi +} + +@(private) +enum_values :: proc(e: ^Emitter, idx: Node_Index) -> []string { + r := e.pool.nodes[idx].enum_values + return e.pool.strings[r.first:][:r.count] +} + +@(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 + return e.pool.children[r.first:][:r.count] +} + +// Collects the struct members of a node, merging $ref targets and allOf +// children depth-first. The first occurrence of a property name wins. +@(private) +collect_members :: proc( + e: ^Emitter, + idx: Node_Index, + members: ^[dynamic]Member, + required: ^map[string]bool, +) { + seen := make(map[Node_Index]bool, context.temp_allocator) + have := make(map[string]bool, context.temp_allocator) + + gather :: proc( + e: ^Emitter, + idx: Node_Index, + members: ^[dynamic]Member, + required: ^map[string]bool, + seen: ^map[Node_Index]bool, + have: ^map[string]bool, + ) { + if seen[idx] { + return + } + seen[idx] = true + node := e.pool.nodes[idx] + if node.ref != NIL_NODE { + gather(e, node.ref, members, required, seen, have) + } + for child in e.pool.children[node.all_of.first:][:node.all_of.count] { + gather(e, child, members, required, seen, have) + } + for name in e.pool.names[node.required.first:][:node.required.count] { + required[name] = true + } + for i in 0 ..< node.props.count { + prop_index := node.props.first + i + prop := e.pool.props[prop_index] + if have[prop.name] { + continue + } + have[prop.name] = true + append(members, Member{prop = prop_index, name = prop.name, node = prop.node}) + } + } + gather(e, idx, members, required, &seen, &have) +} + +// --- by-value cycle detection ------------------------------------------------ +// +// Odin structs cannot contain themselves by value (including through Maybe or +// a union), so any property whose type chain reaches a struct that is already +// being laid out is emitted as json.Value instead. + +@(private) +find_cycles :: proc(e: ^Emitter, idx: Node_Index) { + visit_indirect(e, idx) +} + +// Follows a property's type by value; reports whether it hit a struct that is +// currently on the layout stack. +@(private) +visit_value :: proc(e: ^Emitter, idx: Node_Index) -> (cycles: bool) { + target := effective(e, idx) + switch classify(e, target) { + case .Struct: + switch e.visit[target] { + case .On_Stack: + return true + case .Done: + return false + case .Unvisited: + visit_struct(e, target) + } + case .Union: + for variant in union_variants(e, target) { + if is_null_only(e, effective(e, variant)) { + continue + } + if visit_value(e, variant) { + return true + } + } + case .Multi, .Array: + items := e.pool.nodes[target].items + if items != NIL_NODE { + visit_indirect(e, items) + } + if classify(e, target) == .Multi && .Object in e.pool.nodes[target].types { + additional := e.pool.nodes[target].additional + if additional != NIL_NODE { + visit_indirect(e, additional) + } + } + case .Map: + additional := e.pool.nodes[target].additional + if additional != NIL_NODE { + visit_indirect(e, additional) + } + case .Any, .Boolean, .Integer, .Number, .Str, .Enum: + } + return false +} + +// Crosses an indirection (slice or map), which breaks value containment. +@(private) +visit_indirect :: proc(e: ^Emitter, idx: Node_Index) { + target := effective(e, idx) + switch classify(e, target) { + case .Struct: + if e.visit[target] == .Unvisited { + visit_struct(e, target) + } + case .Union: + for variant in union_variants(e, target) { + visit_indirect(e, variant) + } + case .Multi, .Array: + items := e.pool.nodes[target].items + if items != NIL_NODE { + visit_indirect(e, items) + } + additional := e.pool.nodes[target].additional + if additional != NIL_NODE { + visit_indirect(e, additional) + } + case .Map: + additional := e.pool.nodes[target].additional + if additional != NIL_NODE { + visit_indirect(e, additional) + } + case .Any, .Boolean, .Integer, .Number, .Str, .Enum: + } +} + +@(private) +visit_struct :: proc(e: ^Emitter, idx: Node_Index) { + e.visit[idx] = .On_Stack + members := make([dynamic]Member, context.temp_allocator) + required := make(map[string]bool, context.temp_allocator) + collect_members(e, idx, &members, &required) + for member in members { + if visit_value(e, member.node) { + e.broken[member.prop] = true + } + } + e.visit[idx] = .Done +} + +// --- naming ------------------------------------------------------------------ + +@(private) +unique_name :: proc(e: ^Emitter, base: string) -> string { + if !e.taken[base] { + e.taken[base] = true + return base + } + for n := 2; ; n += 1 { + candidate := strings.concatenate({base, "_", itoa(n)}) + if !e.taken[candidate] { + e.taken[candidate] = true + return candidate + } + } +} + +// Assigns (once) a declaration name to a node and queues it for emission. +@(private) +ensure_decl :: proc(e: ^Emitter, idx: Node_Index, hint: string) -> string { + if e.names[idx] != "" { + return e.names[idx] + } + base := hint + if def, is_def := e.def_names[idx]; is_def { + base = type_name(def) + } + name := unique_name(e, base) + e.names[idx] = name + if !e.queued[idx] { + e.queued[idx] = true + append(&e.queue, idx) + } + return name +} + +// --- type expressions ---------------------------------------------------------- + +// Produces the Odin type expression for a property or element schema. +// union_like types (unions and json.Value) already encode absence as nil, +// so they are never wrapped in Maybe. +@(private) +type_expr :: proc(e: ^Emitter, idx: Node_Index, hint: string) -> (expr: string, union_like: bool, nullable: bool) { + target := effective(e, idx) + node := &e.pool.nodes[target] + nullable = .Null in e.pool.nodes[idx].types || .Null in node.types + + switch classify(e, target) { + case .Any: + e.uses_json = true + return "json.Value", true, nullable + case .Str: + return "string", false, nullable + case .Integer: + return "i64", false, nullable + case .Number: + return "f64", false, nullable + case .Boolean: + return "bool", false, nullable + case .Struct, .Enum: + return ensure_decl(e, target, hint), false, nullable + case .Union: + if _, is_def := e.def_names[target]; is_def || e.names[target] != "" { + return ensure_decl(e, target, hint), true, nullable + } + inline, inline_union_like, nullable_variant := union_expr(e, target, hint) + return inline, inline_union_like, nullable || nullable_variant + case .Multi: + return multi_expr(e, target, hint), true, nullable + case .Array: + item := "json.Value" + if node.items != NIL_NODE { + item, _, _ = type_expr(e, node.items, strings.concatenate({hint, "_Item"})) + } else { + e.uses_json = true + } + return strings.concatenate({"[]", item}), false, nullable + case .Map: + value := "json.Value" + if node.additional != NIL_NODE { + value, _, _ = type_expr(e, node.additional, strings.concatenate({hint, "_Value"})) + } else { + e.uses_json = true + } + return strings.concatenate({"map[string]", value}), false, nullable + } + e.uses_json = true + return "json.Value", true, nullable +} + +@(private) +union_expr :: proc(e: ^Emitter, idx: Node_Index, hint: string) -> (expr: string, union_like: bool, nullable: bool) { + parts := make([dynamic]string, context.temp_allocator) + sole_union_like := false + for variant, i in union_variants(e, idx) { + if is_null_only(e, effective(e, variant)) { + nullable = true + continue + } + variant_hint := strings.concatenate({hint, "_Variant_", itoa(i + 1)}) + part, part_union_like, variant_nullable := type_expr(e, variant, variant_hint) + nullable |= variant_nullable + sole_union_like = part_union_like + append(&parts, part) + } + switch len(parts) { + case 0: + e.uses_json = true + return "json.Value", true, nullable + case 1: + return parts[0], sole_union_like, nullable + } + b: strings.Builder + strings.builder_init(&b) + strings.write_string(&b, "union {") + for part, i in parts { + if i > 0 { + strings.write_string(&b, ", ") + } + strings.write_string(&b, part) + } + strings.write_string(&b, "}") + return strings.to_string(b), true, nullable +} + +// Emission order for multi-type schemas, so output is deterministic. +@(private) +MULTI_ORDER :: [?]Simple_Type{.String, .Integer, .Number, .Boolean, .Array, .Object} + +@(private) +multi_expr :: proc(e: ^Emitter, idx: Node_Index, hint: string) -> string { + node := &e.pool.nodes[idx] + b: strings.Builder + strings.builder_init(&b) + strings.write_string(&b, "union {") + written := 0 + for t in MULTI_ORDER { + if t not_in node.types { + continue + } + if written > 0 { + strings.write_string(&b, ", ") + } + switch t { + case .String: + strings.write_string(&b, "string") + case .Integer: + strings.write_string(&b, "i64") + case .Number: + strings.write_string(&b, "f64") + case .Boolean: + strings.write_string(&b, "bool") + case .Array: + item := "json.Value" + if node.items != NIL_NODE { + item, _, _ = type_expr(e, node.items, strings.concatenate({hint, "_Item"})) + } else { + e.uses_json = true + } + strings.write_string(&b, "[]") + strings.write_string(&b, item) + case .Object: + value := "json.Value" + if node.additional != NIL_NODE { + value, _, _ = type_expr(e, node.additional, strings.concatenate({hint, "_Value"})) + } else { + e.uses_json = true + } + strings.write_string(&b, "map[string]") + strings.write_string(&b, value) + case .Null: + } + written += 1 + } + strings.write_string(&b, "}") + return strings.to_string(b) +} + +// --- declarations ---------------------------------------------------------------- + +@(private) +emit_struct :: proc(e: ^Emitter, idx: Node_Index) { + name := e.names[idx] + members := make([dynamic]Member, context.temp_allocator) + required := make(map[string]bool, context.temp_allocator) + collect_members(e, idx, &members, &required) + + b := &e.body + if len(members) == 0 { + strings.write_string(b, name) + strings.write_string(b, " :: struct {}") + return + } + + strings.write_string(b, name) + strings.write_string(b, " :: struct {\n") + field_names := make(map[string]bool, context.temp_allocator) + for member in members { + fname := field_name(member.name) + if field_names[fname] { + fname = unique_field(fname, &field_names) + } + field_names[fname] = true + + expr: string + union_like: bool + nullable: bool + if e.broken[member.prop] { + e.uses_json = true + expr, union_like = "json.Value", true + } else { + hint := strings.concatenate({name, "_", type_name(member.name)}) + expr, union_like, nullable = type_expr(e, member.node, hint) + } + + optional := !required[member.name] + strings.write_string(b, "\t") + strings.write_string(b, fname) + strings.write_string(b, ": ") + if !union_like && (optional || nullable) { + strings.write_string(b, "Maybe(") + strings.write_string(b, expr) + strings.write_string(b, ")") + } else { + strings.write_string(b, expr) + } + strings.write_string(b, " `json:\"") + strings.write_string(b, member.name) + strings.write_string(b, "\"`,\n") + } + strings.write_string(b, "}") +} + +@(private) +unique_field :: proc(base: string, taken: ^map[string]bool) -> string { + for n := 2; ; n += 1 { + candidate := strings.concatenate({base, "_", itoa(n)}) + if !taken[candidate] { + return candidate + } + } +} + +@(private) +emit_enum :: proc(e: ^Emitter, idx: Node_Index) { + b := &e.body + strings.write_string(b, e.names[idx]) + strings.write_string(b, " :: enum {\n") + for value in enum_values(e, idx) { + strings.write_string(b, "\t") + strings.write_string(b, value) + strings.write_string(b, ",\n") + } + strings.write_string(b, "}") +} + +@(private) +emit_union :: proc(e: ^Emitter, idx: Node_Index) { + name := e.names[idx] + expr, _, _ := union_expr(e, idx, name) + b := &e.body + strings.write_string(b, name) + strings.write_string(b, " :: ") + strings.write_string(b, expr) +} + +// Emits the full Odin source for a resolved pool. +@(private) +emit :: proc(pool: ^Pool, opts: Options) -> 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)), + } + strings.builder_init(&e.body) + + for def in pool.defs { + if _, exists := e.def_names[def.node]; !exists { + e.def_names[def.node] = def.name + } + } + + root := effective(&e, pool.root) + find_cycles(&e, root) + + root_name := opts.root_name if opts.root_name != "" else "Root" + switch classify(&e, root) { + case .Struct, .Enum, .Union: + ensure_decl(&e, root, root_name) + case .Any, .Boolean, .Integer, .Number, .Str, .Array, .Map, .Multi: + // Root is an alias-style declaration. + expr, _, _ := type_expr(&e, root, root_name) + name := unique_name(&e, root_name) + strings.write_string(&e.body, name) + strings.write_string(&e.body, " :: ") + strings.write_string(&e.body, expr) + } + + for i := 0; i < len(e.queue); i += 1 { + if strings.builder_len(e.body) > 0 { + strings.write_string(&e.body, "\n\n") + } + idx := e.queue[i] + switch classify(&e, idx) { + case .Struct: + emit_struct(&e, idx) + case .Enum: + emit_enum(&e, idx) + case .Union: + emit_union(&e, idx) + case .Any, .Boolean, .Integer, .Number, .Str, .Array, .Map, .Multi: + } + } + + package_name := opts.package_name if opts.package_name != "" else "schema" + out: strings.Builder + strings.builder_init(&out) + strings.write_string(&out, "// Code generated by jschema. DO NOT EDIT.\npackage ") + strings.write_string(&out, package_name) + strings.write_string(&out, "\n\n") + if e.uses_json { + strings.write_string(&out, "import \"core:encoding/json\"\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 @@ -0,0 +1,166 @@ +package jschema + +import "core:mem" +import "core:os" +import "core:path/filepath" +import "core:strings" +import "core:testing" + +// Repo root derived from this source file, so tests work from any cwd. +@(private = "file") +repo_root :: proc(allocator := context.allocator) -> string { + context.allocator = allocator + source_dir := filepath.dir(#location().file_path) + pkg_dir := filepath.dir(source_dir) + src_dir := filepath.dir(pkg_dir) + return filepath.dir(src_dir) +} + +@(private = "file") +join :: proc(elems: []string, allocator := context.allocator) -> string { + path, _ := filepath.join(elems, allocator) + return path +} + +// Every testdata case with an expected.odin is a golden test: generating from +// its schema.json must reproduce expected.odin byte for byte. +@(test) +golden :: proc(t: ^testing.T) { + root := repo_root(context.temp_allocator) + cases_dir := join({root, "testdata", "cases"}, context.temp_allocator) + entries, rerr := os.read_all_directory_by_path(cases_dir, context.temp_allocator) + if !testing.expectf(t, rerr == nil, "cannot list %s: %v", cases_dir, rerr) { + return + } + tested := 0 + for entry in entries { + if entry.type != .Directory { + continue + } + expected_path := join({entry.fullpath, "expected.odin"}, context.temp_allocator) + expected, eerr := os.read_entire_file_from_path(expected_path, context.temp_allocator) + if eerr != nil { + continue // no golden for this case (e.g. openapi is end-to-end only) + } + schema_path := join({entry.fullpath, "schema.json"}, context.temp_allocator) + schema, serr := os.read_entire_file_from_path(schema_path, context.temp_allocator) + if !testing.expectf(t, serr == nil, "cannot read %s: %v", schema_path, serr) { + continue + } + got, gerr := generate_source(schema, base_dir = entry.fullpath, allocator = context.temp_allocator) + if !testing.expectf(t, gerr == nil, "%s: generate_source failed: %v", entry.name, gerr) { + continue + } + testing.expectf( + t, + got == string(expected), + "%s: output mismatch\n--- expected ---\n%s\n--- got ---\n%s", + entry.name, + string(expected), + got, + ) + tested += 1 + } + testing.expectf(t, tested >= 12, "expected at least 12 golden cases, ran %d", tested) +} + +// The openapi case has no golden file but must still generate successfully +// and produce substantial output. +@(test) +generates_openapi :: proc(t: ^testing.T) { + root := repo_root(context.temp_allocator) + schema_path := join( + {root, "testdata", "cases", "openapi", "schema.json"}, + context.temp_allocator, + ) + schema, serr := os.read_entire_file_from_path(schema_path, context.temp_allocator) + if !testing.expectf(t, serr == nil, "cannot read %s: %v", schema_path, serr) { + return + } + 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, "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") +} + +@(test) +reports_invalid_json :: proc(t: ^testing.T) { + _, err := generate_source(transmute([]u8)string(`{"type": `), allocator = context.temp_allocator) + _, is_parse_error := err.(Parse_Error) + testing.expectf(t, is_parse_error, "want Parse_Error, got %v", err) +} + +@(test) +reports_unresolvable_ref :: proc(t: ^testing.T) { + schema := transmute([]u8)string(`{"$ref": "#/$defs/missing"}`) + _, err := generate_source(schema, allocator = context.temp_allocator) + resolve_error, is_resolve_error := err.(Resolve_Error) + if !testing.expectf(t, is_resolve_error, "want Resolve_Error, got %v", err) { + return + } + testing.expect_value(t, resolve_error.ref, "#/$defs/missing") +} + +@(test) +reports_missing_ref_file :: proc(t: ^testing.T) { + schema := transmute([]u8)string(`{"$ref": "does_not_exist.json"}`) + _, err := generate_source(schema, allocator = context.temp_allocator) + _, is_io_error := err.(IO_Error) + testing.expectf(t, is_io_error, "want IO_Error, got %v", err) +} + +// generate_source must not leak: the only allocation left for the caller is +// the returned source string. +@(test) +no_leaks :: proc(t: ^testing.T) { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + defer mem.tracking_allocator_destroy(&track) + + schema := transmute([]u8)string(`{ + "type": "object", + "properties": {"a": {"type": "string"}}, + "required": ["a"] + }`) + source, err := generate_source(schema, allocator = mem.tracking_allocator(&track)) + testing.expectf(t, err == nil, "generate_source failed: %v", err) + delete(source, mem.tracking_allocator(&track)) + + for _, entry in track.allocation_map { + testing.expectf(t, false, "leaked %d bytes at %v", entry.size, entry.location) + } + testing.expect_value(t, len(track.bad_free_array), 0) +} + +@(test) +field_names_convert :: proc(t: ^testing.T) { + cases := [][2]string { + {"jsonSchemaDialect", "json_schema_dialect"}, + {"$self", "self"}, + {"path-item", "path_item"}, + {"map", "map_"}, + {"200", "_200"}, + {"", "field"}, + } + for c in cases { + got := field_name(c[0], context.temp_allocator) + testing.expectf(t, got == c[1], "field_name(%q) = %q, want %q", c[0], got, c[1]) + } +} + +@(test) +type_names_convert :: proc(t: ^testing.T) { + cases := [][2]string { + {"path-item", "Path_Item"}, + {"securityScheme", "Security_Scheme"}, + {"info", "Info"}, + {"OAuthFlows", "OAuth_Flows"}, + } + for c in cases { + got := type_name(c[0], context.temp_allocator) + testing.expectf(t, got == c[1], "type_name(%q) = %q, want %q", c[0], got, c[1]) + } +} diff --git a/src/pkg/jschema/jschema.odin b/src/pkg/jschema/jschema.odin @@ -0,0 +1,133 @@ +// Package jschema generates Odin type declarations from a JSON Schema +// document (draft-07 or 2020-12). The generated types parse with +// core:encoding/json. +package jschema + +import "core:encoding/json" +import "core:mem" +import "core:mem/virtual" +import "core:os" +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" +} + +Error :: union { + IO_Error, + Parse_Error, + Resolve_Error, + mem.Allocator_Error, +} + +// A file could not be read or written. +IO_Error :: struct { + path: string, + error: os.Error, +} + +// The schema document is not valid JSON or not a valid schema. +Parse_Error :: struct { + path: string, + pos: json.Pos, + message: string, +} + +// A $ref points at something that does not exist. +Resolve_Error :: struct { + path: string, // file containing the ref + ref: string, // the $ref as written +} + +// Generates an Odin source file from the JSON schema file at schema_path. +// Relative $refs are resolved against the schema file's directory. +generate :: proc(schema_path: string, output_path: string, opts := Options{}) -> Error { + data, rerr := os.read_entire_file_from_path(schema_path, context.allocator) + if rerr != nil { + return IO_Error{path = schema_path, error = rerr} + } + defer delete(data) + + // filepath.dir returns a substring of schema_path; nothing to free. + source, err := generate_source(data, opts, filepath.dir(schema_path)) + if err != nil { + return err + } + defer delete(source) + + if werr := os.write_entire_file(output_path, source); werr != nil { + return IO_Error{path = output_path, error = werr} + } + return nil +} + +// Generates Odin source from an in-memory JSON schema document. Relative +// $refs are resolved against base_dir (the current directory when empty). +// The returned source is allocated with `allocator`; everything else lives in +// an internal arena that is freed before returning. +generate_source :: proc( + data: []u8, + opts := Options{}, + base_dir := "", + allocator := context.allocator, +) -> (source: string, err: Error) { + arena: virtual.Arena + if aerr := virtual.arena_init_growing(&arena); aerr != nil { + return "", aerr + } + defer virtual.arena_destroy(&arena) + + arena_source: string + arena_source, err = generate_in_arena(&arena, data, opts, base_dir) + if err != nil { + return "", clone_error(err, allocator) + } + return strings.clone(arena_source, allocator), nil +} + +@(private) +generate_in_arena :: proc( + arena: ^virtual.Arena, + data: []u8, + opts: Options, + base_dir: string, +) -> (source: string, err: Error) { + context.allocator = virtual.arena_allocator(arena) + context.temp_allocator = context.allocator + + pool: Pool + b := Builder { + pool = &pool, + } + + 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 +} + +// Error strings can point into the internal arena; clone them so the error +// outlives generate_source. +@(private) +clone_error :: proc(err: Error, allocator: mem.Allocator) -> Error { + switch specific in err { + case IO_Error: + return IO_Error{path = strings.clone(specific.path, allocator), error = specific.error} + case Parse_Error: + return Parse_Error { + path = strings.clone(specific.path, allocator), + pos = specific.pos, + message = strings.clone(specific.message, allocator), + } + case Resolve_Error: + return Resolve_Error { + path = strings.clone(specific.path, allocator), + ref = strings.clone(specific.ref, allocator), + } + case mem.Allocator_Error: + return specific + } + return nil +} diff --git a/src/pkg/jschema/name.odin b/src/pkg/jschema/name.odin @@ -0,0 +1,128 @@ +package jschema + +import "core:strings" +import "core:unicode" + +@(private) +RESERVED_WORDS :: []string { + "any", "asm", "auto_cast", "bit_field", "bit_set", "bool", "break", "byte", + "case", "cast", "context", "continue", "cstring", "defer", "distinct", "do", + "dynamic", "else", "enum", "f16", "f32", "f64", "fallthrough", "false", "for", + "foreign", "i128", "i16", "i32", "i64", "i8", "if", "import", "in", "int", + "map", "matrix", "nil", "not_in", "or_break", "or_continue", "or_else", + "or_return", "package", "proc", "quaternion", "rawptr", "return", "rune", + "string", "struct", "switch", "transmute", "true", "typeid", "u128", "u16", + "u32", "u64", "u8", "uint", "uintptr", "union", "using", "when", "where", +} + +@(private) +is_reserved :: proc(s: string) -> bool { + for word in RESERVED_WORDS { + if s == word { + return true + } + } + return false +} + +// A string usable verbatim as an Odin identifier (e.g. an enum variant name). +@(private) +is_valid_ident :: proc(s: string) -> bool { + if len(s) == 0 || is_reserved(s) { + return false + } + for r, i in s { + if i == 0 { + if !unicode.is_letter(r) && r != '_' { + return false + } + } else if !unicode.is_letter(r) && !unicode.is_digit(r) && r != '_' { + return false + } + } + return s != "_" +} + +// Splits on non-alphanumeric runes and lower-to-upper camelCase boundaries. +@(private) +split_words :: proc(s: string, allocator := context.allocator) -> []string { + words := make([dynamic]string, allocator) + start := -1 + prev_lower := false + for r, i in s { + alnum := unicode.is_letter(r) || unicode.is_digit(r) + if !alnum { + if start >= 0 { + append(&words, s[start:i]) + start = -1 + } + prev_lower = false + continue + } + if unicode.is_upper(r) && prev_lower && start >= 0 { + append(&words, s[start:i]) + start = i + } + if start < 0 { + start = i + } + prev_lower = unicode.is_lower(r) || unicode.is_digit(r) + } + if start >= 0 { + append(&words, s[start:]) + } + return words[:] +} + +// Converts a JSON name to an Odin field identifier: "jsonSchemaDialect" -> +// "json_schema_dialect", "$self" -> "self", "type" stays "type". +@(private) +field_name :: proc(s: string, allocator := context.allocator) -> string { + words := split_words(s, allocator) + if len(words) == 0 { + return "field" + } + b: strings.Builder + strings.builder_init(&b, allocator) + for word, i in words { + if i > 0 { + strings.write_byte(&b, '_') + } + for r in word { + strings.write_rune(&b, unicode.to_lower(r)) + } + } + out := strings.to_string(b) + if out[0] >= '0' && out[0] <= '9' { + out = strings.concatenate({"_", out}, allocator) + } + if is_reserved(out) { + out = strings.concatenate({out, "_"}, allocator) + } + return out +} + +// Converts a JSON name to an Odin type identifier: "path-item" -> "Path_Item", +// "securityScheme" -> "Security_Scheme". +@(private) +type_name :: proc(s: string, allocator := context.allocator) -> string { + words := split_words(s, allocator) + if len(words) == 0 { + return "Value" + } + b: strings.Builder + strings.builder_init(&b, allocator) + for word, i in words { + if i > 0 { + strings.write_byte(&b, '_') + } + for r, j in word { + strings.write_rune(&b, unicode.to_upper(r) if j == 0 else r) + } + } + out := strings.to_string(b) + if out[0] >= '0' && out[0] <= '9' { + out = strings.concatenate({"T", out}, allocator) + } + return out +} diff --git a/src/pkg/jschema/pool.odin b/src/pkg/jschema/pool.odin @@ -0,0 +1,527 @@ +package jschema + +import "core:encoding/json" +import "core:strings" + +// The schema document is stored as a flat pool of nodes. All cross references +// are u32 indices into the pool's side arrays rather than pointers, so the +// entire representation lives in a handful of contiguous allocations. + +Node_Index :: distinct u32 + +NIL_NODE :: Node_Index(0xFFFF_FFFF) + +Simple_Type :: enum u8 { + Null, + Boolean, + Object, + Array, + Number, + String, + Integer, +} + +Type_Set :: bit_set[Simple_Type;u8] + +// Index range into one of the pool side arrays. +Range :: struct { + first: u32, + count: u32, +} + +Enum_Kind :: enum u8 { + None, + Strings, // every value is a string + Mixed, // at least one non-string value; falls back to the base type +} + +Bool3 :: enum u8 { + Unset, + False, + True, +} + +Node :: struct { + types: Type_Set, + enum_kind: Enum_Kind, + additional_bool: Bool3, + 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 +} + +Prop :: struct { + name: string, + node: Node_Index, +} + +// A named declaration candidate: a $defs/definitions entry or a referenced +// file's root schema. +Def :: struct { + name: string, + node: Node_Index, +} + +Pool :: struct { + nodes: [dynamic]Node, + props: [dynamic]Prop, + names: [dynamic]string, + children: [dynamic]Node_Index, + strings: [dynamic]string, + defs: [dynamic]Def, + root: Node_Index, +} + +@(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 +} + +@(private) +Builder :: struct { + pool: ^Pool, + // "<file key>#<json pointer>" -> node, for $ref resolution. + pointers: map[string]Node_Index, + // canonical file path -> root node, so each file is parsed once. + files: map[string]Node_Index, + pending: [dynamic]Pending_Ref, + path: string, // display path of the file currently being parsed + file_key: string, // pointer-map key prefix of the current file + dir: string, // directory of the current file +} + +@(private) +new_node :: proc(b: ^Builder, ptr: string) -> Node_Index { + idx := Node_Index(len(b.pool.nodes)) + append(&b.pool.nodes, Node{ref = NIL_NODE, items = NIL_NODE, additional = NIL_NODE}) + b.pointers[strings.concatenate({b.file_key, "#", ptr})] = idx + return idx +} + +// RFC 6901 token encoding, so built pointer paths match the escaped form +// used inside $ref strings. +@(private) +pointer_append :: proc(base: string, key: string) -> string { + escaped, _ := strings.replace_all(key, "~", "~0") + escaped, _ = strings.replace_all(escaped, "/", "~1") + return strings.concatenate({base, "/", escaped}) +} + +@(private) +parse_error :: proc(b: ^Builder, p: ^json.Parser, message: string) -> Error { + return Parse_Error{path = b.path, pos = p.curr_token.pos, message = message} +} + +// Skips one JSON value without building anything. +@(private) +skip_value :: proc(p: ^json.Parser) -> json.Error { + depth := 0 + for { + tok := p.curr_token + #partial switch tok.kind { + case .Open_Brace, .Open_Bracket: + depth += 1 + case .Close_Brace, .Close_Bracket: + depth -= 1 + case .EOF, .Invalid: + return .Unexpected_Token + } + json.advance_token(p) + if depth <= 0 { + return nil + } + } +} + +@(private) +parse_string_value :: proc(b: ^Builder, p: ^json.Parser) -> (s: string, err: Error) { + tok := p.curr_token + if tok.kind != .String { + err = parse_error(b, p, "expected a string value") + return + } + value, uerr := json.unquote_string(tok, p.spec) + if uerr != nil { + err = parse_error(b, p, "invalid string literal") + return + } + json.advance_token(p) + return value, nil +} + +@(private) +type_from_name :: proc(name: string) -> (Simple_Type, bool) { + switch name { + case "null": + return .Null, true + case "boolean": + return .Boolean, true + case "object": + return .Object, true + case "array": + return .Array, true + case "number": + return .Number, true + case "string": + return .String, true + case "integer": + return .Integer, true + } + return .Null, false +} + +// Parses one schema (object or boolean form) into the pool. +@(private) +parse_schema :: proc(b: ^Builder, p: ^json.Parser, ptr: string) -> (idx: Node_Index, err: Error) { + idx = new_node(b, ptr) + + #partial switch p.curr_token.kind { + case .True, .False: + // Boolean schemas constrain nothing we can express as a type. + json.advance_token(p) + return idx, nil + case .Open_Brace: + // handled below + case: + return idx, parse_error(b, p, "expected a schema (object or boolean)") + } + + json.advance_token(p) + for p.curr_token.kind != .Close_Brace { + key, kerr := json.parse_object_key(p, context.allocator) + if kerr != nil { + return idx, parse_error(b, p, "expected an object key") + } + if jerr := json.parse_colon(p); jerr != nil { + return idx, parse_error(b, p, "expected ':' after object key") + } + + switch key { + case "type": + parse_type_keyword(b, p, idx) or_return + case "properties": + parse_properties(b, p, idx, ptr) or_return + case "required": + parse_required(b, p, idx) or_return + case "items": + // Draft-07 tuple form (array of schemas) degrades to untyped items. + if p.curr_token.kind == .Open_Bracket { + parse_schema_list(b, p, pointer_append(ptr, "items")) or_return + } else { + items := parse_schema(b, p, pointer_append(ptr, "items")) or_return + b.pool.nodes[idx].items = items + } + case "additionalProperties": + #partial switch p.curr_token.kind { + case .True: + b.pool.nodes[idx].additional_bool = .True + json.advance_token(p) + case .False: + b.pool.nodes[idx].additional_bool = .False + json.advance_token(p) + case: + additional := parse_schema(b, p, pointer_append(ptr, "additionalProperties")) or_return + b.pool.nodes[idx].additional = additional + } + case "patternProperties": + parse_pattern_properties(b, p, idx, ptr) or_return + case "enum": + parse_enum(b, p, idx) or_return + case "const": + parse_const(b, p, idx) or_return + 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 "$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 + case "anyOf": + 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 + case: + if jerr := skip_value(p); jerr != nil { + return idx, parse_error(b, p, "malformed JSON value") + } + } + + if json.parse_comma(p) { + break + } + } + if jerr := json.expect_token(p, .Close_Brace); jerr != nil { + return idx, parse_error(b, p, "expected '}' to close schema object") + } + return idx, nil +} + +@(private) +parse_type_keyword :: proc(b: ^Builder, p: ^json.Parser, idx: Node_Index) -> Error { + add :: proc(b: ^Builder, p: ^json.Parser, idx: Node_Index) -> Error { + name, err := parse_string_value(b, p) + if err != nil { + return err + } + t, ok := type_from_name(name) + if !ok { + return parse_error(b, p, "unknown value in \"type\"") + } + b.pool.nodes[idx].types += {t} + return nil + } + + if p.curr_token.kind != .Open_Bracket { + return add(b, p, idx) + } + json.advance_token(p) + for p.curr_token.kind != .Close_Bracket { + add(b, p, idx) or_return + if json.parse_comma(p) { + break + } + } + if jerr := json.expect_token(p, .Close_Bracket); jerr != nil { + return parse_error(b, p, "expected ']' to close \"type\" array") + } + return nil +} + +@(private) +parse_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 \"properties\"") + } + base := pointer_append(ptr, "properties") + // Children are parsed first (appending nodes and their own props freely); + // this node's prop records are appended afterwards so they stay contiguous. + local := make([dynamic]Prop, context.temp_allocator) + for p.curr_token.kind != .Close_Brace { + name, kerr := json.parse_object_key(p, context.allocator) + if kerr != nil { + return parse_error(b, p, "expected a property name") + } + if jerr := json.parse_colon(p); jerr != nil { + return parse_error(b, p, "expected ':' after property name") + } + child, err := parse_schema(b, p, pointer_append(base, name)) + if err != nil { + return err + } + append(&local, Prop{name = name, node = child}) + if json.parse_comma(p) { + break + } + } + if jerr := json.expect_token(p, .Close_Brace); jerr != nil { + return parse_error(b, p, "expected '}' to close \"properties\"") + } + b.pool.nodes[idx].props = Range{u32(len(b.pool.props)), u32(len(local))} + append(&b.pool.props, ..local[:]) + return nil +} + +@(private) +parse_required :: proc(b: ^Builder, p: ^json.Parser, idx: Node_Index) -> Error { + if jerr := json.expect_token(p, .Open_Bracket); jerr != nil { + return parse_error(b, p, "expected an array for \"required\"") + } + first := u32(len(b.pool.names)) + for p.curr_token.kind != .Close_Bracket { + name, err := parse_string_value(b, p) + if err != nil { + return err + } + append(&b.pool.names, name) + if json.parse_comma(p) { + break + } + } + if jerr := json.expect_token(p, .Close_Bracket); jerr != nil { + return parse_error(b, p, "expected ']' to close \"required\"") + } + b.pool.nodes[idx].required = Range{first, u32(len(b.pool.names)) - first} + return nil +} + +@(private) +parse_enum :: proc(b: ^Builder, p: ^json.Parser, idx: Node_Index) -> Error { + if jerr := json.expect_token(p, .Open_Bracket); jerr != nil { + return parse_error(b, p, "expected an array for \"enum\"") + } + kind := Enum_Kind.Strings + local := make([dynamic]string, context.temp_allocator) + for p.curr_token.kind != .Close_Bracket { + if p.curr_token.kind == .String { + value, err := parse_string_value(b, p) + if err != nil { + return err + } + append(&local, value) + } else { + kind = .Mixed + if jerr := skip_value(p); jerr != nil { + return parse_error(b, p, "malformed value in \"enum\"") + } + } + if json.parse_comma(p) { + break + } + } + if jerr := json.expect_token(p, .Close_Bracket); jerr != nil { + return parse_error(b, p, "expected ']' to close \"enum\"") + } + node := &b.pool.nodes[idx] + node.enum_kind = kind + if kind == .Strings { + node.enum_values = Range{u32(len(b.pool.strings)), u32(len(local))} + append(&b.pool.strings, ..local[:]) + } + return nil +} + +@(private) +parse_const :: proc(b: ^Builder, p: ^json.Parser, idx: Node_Index) -> Error { + if p.curr_token.kind == .String { + value := parse_string_value(b, p) or_return + node := &b.pool.nodes[idx] + if node.enum_kind == .None { + node.enum_kind = .Strings + node.enum_values = Range{u32(len(b.pool.strings)), 1} + append(&b.pool.strings, value) + } + return nil + } + if jerr := skip_value(p); jerr != nil { + return parse_error(b, p, "malformed value in \"const\"") + } + return nil +} + +// 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 { + if jerr := json.expect_token(p, .Open_Brace); jerr != nil { + return parse_error(b, p, "expected an object for \"patternProperties\"") + } + base := pointer_append(ptr, "patternProperties") + count := 0 + sole := NIL_NODE + for p.curr_token.kind != .Close_Brace { + pattern, kerr := json.parse_object_key(p, context.allocator) + if kerr != nil { + return parse_error(b, p, "expected a pattern key") + } + if jerr := json.parse_colon(p); jerr != nil { + return parse_error(b, p, "expected ':' after pattern") + } + child, err := parse_schema(b, p, pointer_append(base, pattern)) + if err != nil { + return err + } + count += 1 + sole = child if count == 1 else NIL_NODE + if json.parse_comma(p) { + break + } + } + if jerr := json.expect_token(p, .Close_Brace); jerr != nil { + return parse_error(b, p, "expected '}' to close \"patternProperties\"") + } + if sole != NIL_NODE && b.pool.nodes[idx].additional == NIL_NODE { + b.pool.nodes[idx].additional = sole + } + return nil +} + +@(private) +parse_defs :: proc(b: ^Builder, p: ^json.Parser, ptr: string) -> Error { + if jerr := json.expect_token(p, .Open_Brace); jerr != nil { + return parse_error(b, p, "expected an object for \"$defs\"") + } + for p.curr_token.kind != .Close_Brace { + name, kerr := json.parse_object_key(p, context.allocator) + if kerr != nil { + return parse_error(b, p, "expected a definition name") + } + if jerr := json.parse_colon(p); jerr != nil { + return parse_error(b, p, "expected ':' after definition name") + } + child, err := parse_schema(b, p, pointer_append(ptr, name)) + if err != nil { + return err + } + append(&b.pool.defs, Def{name = name, node = child}) + if json.parse_comma(p) { + break + } + } + if jerr := json.expect_token(p, .Close_Brace); jerr != nil { + return parse_error(b, p, "expected '}' to close \"$defs\"") + } + return nil +} + +@(private) +parse_schema_list :: proc(b: ^Builder, p: ^json.Parser, ptr: string) -> (r: Range, err: Error) { + if jerr := json.expect_token(p, .Open_Bracket); jerr != nil { + err = parse_error(b, p, "expected an array of schemas") + return + } + local := make([dynamic]Node_Index, context.temp_allocator) + for p.curr_token.kind != .Close_Bracket { + child := parse_schema(b, p, pointer_append(ptr, itoa(len(local)))) or_return + append(&local, child) + if json.parse_comma(p) { + break + } + } + if jerr := json.expect_token(p, .Close_Bracket); jerr != nil { + err = parse_error(b, p, "expected ']' to close schema array") + return + } + r = Range{u32(len(b.pool.children)), u32(len(local))} + append(&b.pool.children, ..local[:]) + return r, nil +} + +@(private) +itoa :: proc(n: int) -> string { + buf: [12]u8 + i := len(buf) + n := n + if n == 0 { + return "0" + } + for n > 0 { + i -= 1 + buf[i] = '0' + u8(n % 10) + n /= 10 + } + return strings.clone(string(buf[i:])) +} + +// 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) { + 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 + + p := json.make_parser(data, .JSON, true, context.allocator) + root = parse_schema(b, &p, "") or_return + if p.curr_token.kind != .EOF { + return root, parse_error(b, &p, "trailing content after schema") + } + return root, nil +} diff --git a/src/pkg/jschema/resolve.odin b/src/pkg/jschema/resolve.odin @@ -0,0 +1,64 @@ +package jschema + +import "core:os" +import "core:path/filepath" +import "core:strings" + +// Resolves every pending $ref, loading relative schema files on demand. +// Loading a file can append more pending refs, so this drains by index. +@(private) +resolve_refs :: proc(b: ^Builder) -> Error { + for i := 0; i < len(b.pending); i += 1 { + pending := b.pending[i] + + fragment := pending.ref + file_part := "" + if hash := strings.index_byte(pending.ref, '#'); hash >= 0 { + file_part = pending.ref[:hash] + fragment = pending.ref[hash:] + } else { + file_part = pending.ref + fragment = "#" + } + + file_key := pending.file + if file_part != "" { + file_key = resolve_file(b, file_part, pending.dir) or_return + } + + node, found := b.pointers[strings.concatenate({file_key, fragment})] + if !found { + return Resolve_Error{path = pending.file, ref = pending.ref} + } + b.pool.nodes[pending.node].ref = node + } + return nil +} + +// Loads (once) the schema file at `name` relative to `dir` and registers its +// root as a named def. Returns the file's pointer-map key. +@(private) +resolve_file :: proc(b: ^Builder, name: string, dir: string) -> (key: string, err: Error) { + path := name + if !filepath.is_abs(name) { + path, _ = filepath.join({dir, name}) + } + if cleaned, aerr := filepath.abs(path); aerr == nil { + path = cleaned + } + + if _, loaded := b.files[path]; loaded { + return path, nil + } + + data, rerr := os.read_entire_file_from_path(path, context.allocator) + if rerr != nil { + return "", IO_Error{path = path, error = rerr} + } + + root := parse_document(b, data, path, path, filepath.dir(path)) or_return + b.files[path] = root + stem := filepath.stem(filepath.base(path)) + append(&b.pool.defs, Def{name = stem, node = root}) + return path, nil +} diff --git a/testdata/cases/allof/check.odin b/testdata/cases/allof/check.odin @@ -0,0 +1,15 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, root.id, 1) + testing.expect_value(t, root.name, "merged") +} diff --git a/testdata/cases/allof/expected.odin b/testdata/cases/allof/expected.odin @@ -0,0 +1,7 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + id: i64 `json:"id"`, + name: string `json:"name"`, +} diff --git a/testdata/cases/allof/sample.json b/testdata/cases/allof/sample.json @@ -0,0 +1 @@ +{ "id": 1, "name": "merged" } diff --git a/testdata/cases/allof/schema.json b/testdata/cases/allof/schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] + } + ], + "$defs": { + "base": { + "type": "object", + "properties": { + "id": { "type": "integer" } + }, + "required": ["id"] + } + } +} diff --git a/testdata/cases/anyof/check.odin b/testdata/cases/anyof/check.odin @@ -0,0 +1,18 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + value, is_number := root.value.(f64) + testing.expect(t, is_number, "value should parse as f64") + testing.expect_value(t, value, 4.5) + _, has_note := root.note.? + testing.expect_value(t, has_note, false) +} diff --git a/testdata/cases/anyof/expected.odin b/testdata/cases/anyof/expected.odin @@ -0,0 +1,7 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + value: union {string, f64} `json:"value"`, + note: Maybe(string) `json:"note"`, +} diff --git a/testdata/cases/anyof/sample.json b/testdata/cases/anyof/sample.json @@ -0,0 +1 @@ +{ "value": 4.5, "note": null } diff --git a/testdata/cases/anyof/schema.json b/testdata/cases/anyof/schema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "value": { + "anyOf": [{ "type": "string" }, { "type": "number" }] + }, + "note": { + "anyOf": [{ "type": "string" }, { "type": "null" }] + } + }, + "required": ["value"] +} diff --git a/testdata/cases/arrays/check.odin b/testdata/cases/arrays/check.odin @@ -0,0 +1,21 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, len(root.points), 2) + testing.expect_value(t, root.points[1].x, 3) + mat := root.matrix_.? or_else nil + testing.expect_value(t, len(mat), 2) + testing.expect_value(t, mat[1][0], 3) + tags := root.tags.? or_else nil + testing.expect_value(t, len(tags), 1) + testing.expect_value(t, tags[0], "a") +} diff --git a/testdata/cases/arrays/expected.odin b/testdata/cases/arrays/expected.odin @@ -0,0 +1,13 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + points: []Root_Points_Item `json:"points"`, + matrix_: Maybe([][]f64) `json:"matrix"`, + tags: Maybe([]string) `json:"tags"`, +} + +Root_Points_Item :: struct { + x: f64 `json:"x"`, + y: f64 `json:"y"`, +} diff --git a/testdata/cases/arrays/sample.json b/testdata/cases/arrays/sample.json @@ -0,0 +1,5 @@ +{ + "points": [{ "x": 1, "y": 2 }, { "x": 3, "y": 4 }], + "matrix": [[1, 2], [3, 4]], + "tags": ["a"] +} diff --git a/testdata/cases/arrays/schema.json b/testdata/cases/arrays/schema.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" } + }, + "required": ["x", "y"] + } + }, + "matrix": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "number" } + } + }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["points"] +} diff --git a/testdata/cases/draft7/check.odin b/testdata/cases/draft7/check.odin @@ -0,0 +1,17 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, root.start.x, 1) + testing.expect_value(t, root.start.y, 2) + testing.expect_value(t, root.end.x, 3.5) + testing.expect_value(t, root.end.y, -1) +} diff --git a/testdata/cases/draft7/expected.odin b/testdata/cases/draft7/expected.odin @@ -0,0 +1,12 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + start: Point `json:"start"`, + end: Point `json:"end"`, +} + +Point :: struct { + x: f64 `json:"x"`, + y: f64 `json:"y"`, +} diff --git a/testdata/cases/draft7/sample.json b/testdata/cases/draft7/sample.json @@ -0,0 +1 @@ +{ "start": { "x": 1, "y": 2 }, "end": { "x": 3.5, "y": -1 } } diff --git a/testdata/cases/draft7/schema.json b/testdata/cases/draft7/schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "start": { "$ref": "#/definitions/point" }, + "end": { "$ref": "#/definitions/point" } + }, + "required": ["start", "end"], + "definitions": { + "point": { + "type": "object", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" } + }, + "required": ["x", "y"] + } + } +} diff --git a/testdata/cases/enums/check.odin b/testdata/cases/enums/check.odin @@ -0,0 +1,15 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, root.status, Root_Status.active) + testing.expect_value(t, root.kind, "a-b") +} diff --git a/testdata/cases/enums/expected.odin b/testdata/cases/enums/expected.odin @@ -0,0 +1,13 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + status: Root_Status `json:"status"`, + kind: string `json:"kind"`, +} + +Root_Status :: enum { + pending, + active, + closed, +} diff --git a/testdata/cases/enums/sample.json b/testdata/cases/enums/sample.json @@ -0,0 +1 @@ +{ "status": "active", "kind": "a-b" } diff --git a/testdata/cases/enums/schema.json b/testdata/cases/enums/schema.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "status": { "enum": ["pending", "active", "closed"] }, + "kind": { "enum": ["a-b", "c d", "1x"] } + }, + "required": ["status", "kind"] +} diff --git a/testdata/cases/maps/check.odin b/testdata/cases/maps/check.odin @@ -0,0 +1,18 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, len(root.scores), 2) + testing.expect_value(t, root.scores["alpha"], 1.5) + meta := root.meta.? or_else nil + testing.expect_value(t, len(meta), 1) + testing.expect_value(t, meta["a"].value, "x") +} diff --git a/testdata/cases/maps/expected.odin b/testdata/cases/maps/expected.odin @@ -0,0 +1,11 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + scores: map[string]f64 `json:"scores"`, + meta: Maybe(map[string]Entry) `json:"meta"`, +} + +Entry :: struct { + value: string `json:"value"`, +} diff --git a/testdata/cases/maps/sample.json b/testdata/cases/maps/sample.json @@ -0,0 +1,4 @@ +{ + "scores": { "alpha": 1.5, "beta": 2 }, + "meta": { "a": { "value": "x" } } +} diff --git a/testdata/cases/maps/schema.json b/testdata/cases/maps/schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "scores": { + "type": "object", + "additionalProperties": { "type": "number" } + }, + "meta": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/entry" } + } + }, + "required": ["scores"], + "$defs": { + "entry": { + "type": "object", + "properties": { + "value": { "type": "string" } + }, + "required": ["value"] + } + } +} diff --git a/testdata/cases/multifile/address.json b/testdata/cases/multifile/address.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } + }, + "required": ["city"] +} diff --git a/testdata/cases/multifile/check.odin b/testdata/cases/multifile/check.odin @@ -0,0 +1,17 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, root.name, "grug") + address := root.address.? or_else Address{} + testing.expect_value(t, address.city, "Rockville") + testing.expect_value(t, address.street.? or_else "", "1 Cave Way") +} diff --git a/testdata/cases/multifile/expected.odin b/testdata/cases/multifile/expected.odin @@ -0,0 +1,12 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + name: string `json:"name"`, + address: Maybe(Address) `json:"address"`, +} + +Address :: struct { + street: Maybe(string) `json:"street"`, + city: string `json:"city"`, +} diff --git a/testdata/cases/multifile/sample.json b/testdata/cases/multifile/sample.json @@ -0,0 +1,4 @@ +{ + "name": "grug", + "address": { "street": "1 Cave Way", "city": "Rockville" } +} diff --git a/testdata/cases/multifile/schema.json b/testdata/cases/multifile/schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" }, + "address": { "$ref": "address.json" } + }, + "required": ["name"] +} diff --git a/testdata/cases/nullable/check.odin b/testdata/cases/nullable/check.odin @@ -0,0 +1,19 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + _, has_name := root.name.? + testing.expect_value(t, has_name, false) + testing.expect_value(t, root.age.? or_else 0, 30) + tag, is_int := root.tag.(i64) + testing.expect(t, is_int, "tag should parse as i64") + testing.expect_value(t, tag, 7) +} diff --git a/testdata/cases/nullable/expected.odin b/testdata/cases/nullable/expected.odin @@ -0,0 +1,8 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + name: Maybe(string) `json:"name"`, + age: Maybe(i64) `json:"age"`, + tag: union {string, i64} `json:"tag"`, +} diff --git a/testdata/cases/nullable/sample.json b/testdata/cases/nullable/sample.json @@ -0,0 +1 @@ +{ "name": null, "age": 30, "tag": 7 } diff --git a/testdata/cases/nullable/schema.json b/testdata/cases/nullable/schema.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { "type": ["string", "null"] }, + "age": { "type": ["integer", "null"] }, + "tag": { "type": ["string", "integer", "null"] } + }, + "required": ["name", "tag"] +} diff --git a/testdata/cases/oneof/check.odin b/testdata/cases/oneof/check.odin @@ -0,0 +1,31 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + // NOTE: core:encoding/json tries union variants in declaration order and + // skips unknown object keys, so the first object variant that parses wins. + circle, is_circle := root.shape.(Circle) + testing.expect(t, is_circle, "shape should parse as Circle") + testing.expect_value(t, circle.radius, 2.5) + value, is_string := root.value.(string) + testing.expect(t, is_string, "value should parse as string") + testing.expect_value(t, value, "hi") +} + +@(test) +parses_number_variant :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(`{ "shape": { "radius": 1 }, "value": 9.5 }`, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + value, is_number := root.value.(f64) + testing.expect(t, is_number, "value should parse as f64") + testing.expect_value(t, value, 9.5) +} diff --git a/testdata/cases/oneof/expected.odin b/testdata/cases/oneof/expected.odin @@ -0,0 +1,16 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + shape: union {Circle, Rect} `json:"shape"`, + value: union {string, f64} `json:"value"`, +} + +Circle :: struct { + radius: f64 `json:"radius"`, +} + +Rect :: struct { + width: f64 `json:"width"`, + height: f64 `json:"height"`, +} diff --git a/testdata/cases/oneof/sample.json b/testdata/cases/oneof/sample.json @@ -0,0 +1 @@ +{ "shape": { "radius": 2.5 }, "value": "hi" } diff --git a/testdata/cases/oneof/schema.json b/testdata/cases/oneof/schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "shape": { + "oneOf": [{ "$ref": "#/$defs/circle" }, { "$ref": "#/$defs/rect" }] + }, + "value": { + "oneOf": [{ "type": "string" }, { "type": "number" }] + } + }, + "required": ["shape", "value"], + "$defs": { + "circle": { + "type": "object", + "properties": { + "radius": { "type": "number" } + }, + "required": ["radius"] + }, + "rect": { + "type": "object", + "properties": { + "width": { "type": "number" }, + "height": { "type": "number" } + }, + "required": ["width", "height"] + } + } +} diff --git a/testdata/cases/openapi/check.odin b/testdata/cases/openapi/check.odin @@ -0,0 +1,16 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, root.openapi, "3.2.0") + testing.expect_value(t, root.info.title, "Pet Store") + testing.expect_value(t, root.info.version, "1.0.0") +} diff --git a/testdata/cases/openapi/sample.json b/testdata/cases/openapi/sample.json @@ -0,0 +1,14 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Pet Store", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": { + "summary": "List pets", + "responses": { + "200": { "description": "ok" } + } + } + } + } +} diff --git a/testdata/cases/openapi/schema.json b/testdata/cases/openapi/schema.json @@ -0,0 +1,1536 @@ +{ + "$id": "https://spec.openapis.org/oas/3.2/schema/2025-11-23", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The description of OpenAPI v3.2.x Documents without Schema Object validation", + "type": "object", + "properties": { + "openapi": { + "type": "string", + "pattern": "^3\\.2\\.\\d+(-.+)?$" + }, + "$self": { + "type": "string", + "format": "uri-reference", + "$comment": "MUST NOT contain a fragment", + "pattern": "^[^#]*$" + }, + "info": { + "$ref": "#/$defs/info" + }, + "jsonSchemaDialect": { + "type": "string", + "format": "uri-reference", + "default": "https://spec.openapis.org/oas/3.2/dialect/2025-09-17" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + }, + "default": [ + { + "url": "/" + } + ] + }, + "paths": { + "$ref": "#/$defs/paths" + }, + "webhooks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item" + } + }, + "components": { + "$ref": "#/$defs/components" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/$defs/security-requirement" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/$defs/tag" + } + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + } + }, + "required": ["openapi", "info"], + "anyOf": [ + { + "required": ["paths"] + }, + { + "required": ["components"] + }, + { + "required": ["webhooks"] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "info": { + "$comment": "https://spec.openapis.org/oas/v3.2#info-object", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "termsOfService": { + "type": "string", + "format": "uri-reference" + }, + "contact": { + "$ref": "#/$defs/contact" + }, + "license": { + "$ref": "#/$defs/license" + }, + "version": { + "type": "string" + } + }, + "required": ["title", "version"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "contact": { + "$comment": "https://spec.openapis.org/oas/v3.2#contact-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "license": { + "$comment": "https://spec.openapis.org/oas/v3.2#license-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "required": ["name"], + "dependentSchemas": { + "identifier": { + "not": { + "required": ["url"] + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "server": { + "$comment": "https://spec.openapis.org/oas/v3.2#server-object", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "variables": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/server-variable" + } + } + }, + "required": ["url"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "server-variable": { + "$comment": "https://spec.openapis.org/oas/v3.2#server-variable-object", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["default"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "components": { + "$comment": "https://spec.openapis.org/oas/v3.2#components-object", + "type": "object", + "properties": { + "schemas": { + "type": "object", + "additionalProperties": { + "$dynamicRef": "#meta" + } + }, + "responses": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/response-or-reference" + } + }, + "parameters": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + }, + "requestBodies": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/request-body-or-reference" + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "securitySchemes": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/security-scheme-or-reference" + } + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/link-or-reference" + } + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/callbacks-or-reference" + } + }, + "pathItems": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item" + } + }, + "mediaTypes": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/media-type-or-reference" + } + } + }, + "patternProperties": { + "^(?:schemas|responses|parameters|examples|requestBodies|headers|securitySchemes|links|callbacks|pathItems|mediaTypes)$": { + "$comment": "Enumerating all of the property names in the regex above is necessary for unevaluatedProperties to work as expected", + "propertyNames": { + "pattern": "^[a-zA-Z0-9._-]+$" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "paths": { + "$comment": "https://spec.openapis.org/oas/v3.2#paths-object", + "type": "object", + "patternProperties": { + "^/": { + "$ref": "#/$defs/path-item" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "path-item": { + "$comment": "https://spec.openapis.org/oas/v3.2#path-item-object", + "type": "object", + "properties": { + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + }, + "parameters": { + "$ref": "#/$defs/parameters" + }, + "additionalOperations": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/operation" + }, + "propertyNames": { + "$comment": "RFC9110 restricts methods to \"1*tchar\" in ABNF", + "pattern": "^[a-zA-Z0-9!#$%&'*+.^_`|~-]+$", + "not": { + "enum": [ + "GET", + "PUT", + "POST", + "DELETE", + "OPTIONS", + "HEAD", + "PATCH", + "TRACE", + "QUERY" + ] + } + } + }, + "get": { + "$ref": "#/$defs/operation" + }, + "put": { + "$ref": "#/$defs/operation" + }, + "post": { + "$ref": "#/$defs/operation" + }, + "delete": { + "$ref": "#/$defs/operation" + }, + "options": { + "$ref": "#/$defs/operation" + }, + "head": { + "$ref": "#/$defs/operation" + }, + "patch": { + "$ref": "#/$defs/operation" + }, + "trace": { + "$ref": "#/$defs/operation" + }, + "query": { + "$ref": "#/$defs/operation" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "operation": { + "$comment": "https://spec.openapis.org/oas/v3.2#operation-object", + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/parameters" + }, + "requestBody": { + "$ref": "#/$defs/request-body-or-reference" + }, + "responses": { + "$ref": "#/$defs/responses" + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/callbacks-or-reference" + } + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/$defs/security-requirement" + } + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "external-documentation": { + "$comment": "https://spec.openapis.org/oas/v3.2#external-documentation-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "required": ["url"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + }, + "not": { + "allOf": [ + { + "contains": { + "type": "object", + "properties": { + "in": { + "const": "query" + } + }, + "required": ["in"] + } + }, + { + "contains": { + "type": "object", + "properties": { + "in": { + "const": "querystring" + } + }, + "required": ["in"] + } + } + ] + }, + "contains": { + "type": "object", + "properties": { + "in": { + "const": "querystring" + } + }, + "required": ["in"] + }, + "minContains": 0, + "maxContains": 1 + }, + "parameter": { + "$comment": "https://spec.openapis.org/oas/v3.2#parameter-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": ["query", "querystring", "header", "path", "cookie"] + }, + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "content": { + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "required": ["name", "in"], + "oneOf": [ + { + "required": ["schema"] + }, + { + "required": ["content"] + } + ], + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$ref": "#/$defs/specification-extensions" + }, + { + "if": { + "properties": { + "in": { + "const": "query" + } + } + }, + "then": { + "properties": { + "allowEmptyValue": { + "default": false, + "type": "boolean" + } + } + } + }, + { + "if": { + "properties": { + "in": { + "const": "querystring" + } + } + }, + "then": { + "required": ["content"] + } + } + ], + "dependentSchemas": { + "schema": { + "properties": { + "style": { + "type": "string" + }, + "explode": { + "type": "boolean" + } + }, + "allOf": [ + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-path" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-header" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-query" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-cookie" + } + ], + "$defs": { + "styles-for-path": { + "if": { + "properties": { + "in": { + "const": "path" + } + } + }, + "then": { + "properties": { + "name": { + "pattern": "^[^{}]+$" + }, + "style": { + "default": "simple", + "enum": ["matrix", "label", "simple"] + }, + "required": { + "const": true + }, + "explode": { + "default": false + }, + "allowReserved": { + "type": "boolean", + "default": false + } + }, + "required": ["required"] + } + }, + "styles-for-header": { + "if": { + "properties": { + "in": { + "const": "header" + } + } + }, + "then": { + "properties": { + "style": { + "default": "simple", + "const": "simple" + }, + "explode": { + "default": false + } + } + } + }, + "styles-for-query": { + "if": { + "properties": { + "in": { + "const": "query" + } + } + }, + "then": { + "properties": { + "style": { + "default": "form", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "allowReserved": { + "type": "boolean", + "default": false + } + }, + "$ref": "#/$defs/explode-for-form" + } + }, + "styles-for-cookie": { + "if": { + "properties": { + "in": { + "const": "cookie" + } + } + }, + "then": { + "properties": { + "style": { + "default": "form", + "enum": ["form", "cookie"] + }, + "explode": { + "default": true + } + }, + "if": { + "properties": { + "style": { + "const": "form" + } + } + }, + "then": { + "properties": { + "allowReserved": { + "type": "boolean", + "default": false + } + } + } + } + } + } + } + }, + "unevaluatedProperties": false + }, + "parameter-or-reference": { + "if": { + "type": "object", + "required": ["$ref"] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/parameter" + } + }, + "request-body": { + "$comment": "https://spec.openapis.org/oas/v3.2#request-body-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "content": { + "$ref": "#/$defs/content" + }, + "required": { + "default": false, + "type": "boolean" + } + }, + "required": ["content"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "request-body-or-reference": { + "if": { + "type": "object", + "required": ["$ref"] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/request-body" + } + }, + "content": { + "$comment": "https://spec.openapis.org/oas/v3.2#fixed-fields-10", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/media-type-or-reference" + }, + "propertyNames": { + "format": "media-range" + } + }, + "media-type": { + "$comment": "https://spec.openapis.org/oas/v3.2#media-type-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "itemSchema": { + "$dynamicRef": "#meta" + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/encoding" + } + }, + "prefixEncoding": { + "type": "array", + "items": { + "$ref": "#/$defs/encoding" + } + }, + "itemEncoding": { + "$ref": "#/$defs/encoding" + } + }, + "dependentSchemas": { + "encoding": { + "properties": { + "prefixEncoding": false, + "itemEncoding": false + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$ref": "#/$defs/specification-extensions" + } + ], + "unevaluatedProperties": false + }, + "media-type-or-reference": { + "if": { + "type": "object", + "required": ["$ref"] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/media-type" + } + }, + "encoding": { + "$comment": "https://spec.openapis.org/oas/v3.2#encoding-object", + "type": "object", + "properties": { + "contentType": { + "type": "string", + "format": "media-range" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "style": { + "enum": ["form", "spaceDelimited", "pipeDelimited", "deepObject"] + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean" + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/encoding" + } + }, + "prefixEncoding": { + "type": "array", + "items": { + "$ref": "#/$defs/encoding" + } + }, + "itemEncoding": { + "$ref": "#/$defs/encoding" + } + }, + "dependentSchemas": { + "encoding": { + "properties": { + "prefixEncoding": false, + "itemEncoding": false + } + }, + "style": { + "properties": { + "allowReserved": { + "default": false + } + }, + "$ref": "#/$defs/explode-for-form" + }, + "explode": { + "properties": { + "style": { + "default": "form" + }, + "allowReserved": { + "default": false + } + } + }, + "allowReserved": { + "properties": { + "style": { + "default": "form" + } + }, + "$ref": "#/$defs/explode-for-form" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "responses": { + "$comment": "https://spec.openapis.org/oas/v3.2#responses-object", + "type": "object", + "properties": { + "default": { + "$ref": "#/$defs/response-or-reference" + } + }, + "patternProperties": { + "^[1-5](?:[0-9]{2}|XX)$": { + "$ref": "#/$defs/response-or-reference" + } + }, + "minProperties": 1, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "if": { + "$comment": "either default, or at least one response code property must exist", + "patternProperties": { + "^[1-5](?:[0-9]{2}|XX)$": false + } + }, + "then": { + "required": ["default"] + } + }, + "response": { + "$comment": "https://spec.openapis.org/oas/v3.2#response-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "content": { + "$ref": "#/$defs/content" + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/link-or-reference" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "response-or-reference": { + "if": { + "type": "object", + "required": ["$ref"] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/response" + } + }, + "callbacks": { + "$comment": "https://spec.openapis.org/oas/v3.2#callback-object", + "type": "object", + "$ref": "#/$defs/specification-extensions", + "additionalProperties": { + "$ref": "#/$defs/path-item" + } + }, + "callbacks-or-reference": { + "if": { + "type": "object", + "required": ["$ref"] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/callbacks" + } + }, + "example": { + "$comment": "https://spec.openapis.org/oas/v3.2#example-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "dataValue": true, + "serializedValue": { + "type": "string" + }, + "value": true, + "externalValue": { + "type": "string", + "format": "uri-reference" + } + }, + "allOf": [ + { + "not": { + "required": ["value", "externalValue"] + } + }, + { + "not": { + "required": ["value", "dataValue"] + } + }, + { + "not": { + "required": ["value", "serializedValue"] + } + }, + { + "not": { + "required": ["serializedValue", "externalValue"] + } + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "example-or-reference": { + "if": { + "type": "object", + "required": ["$ref"] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/example" + } + }, + "link": { + "$comment": "https://spec.openapis.org/oas/v3.2#link-object", + "type": "object", + "properties": { + "operationRef": { + "type": "string", + "format": "uri-reference" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/map-of-strings" + }, + "requestBody": true, + "description": { + "type": "string" + }, + "server": { + "$ref": "#/$defs/server" + } + }, + "oneOf": [ + { + "required": ["operationRef"] + }, + { + "required": ["operationId"] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "link-or-reference": { + "if": { + "type": "object", + "required": ["$ref"] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/link" + } + }, + "header": { + "$comment": "https://spec.openapis.org/oas/v3.2#header-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "content": { + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "oneOf": [ + { + "required": ["schema"] + }, + { + "required": ["content"] + } + ], + "dependentSchemas": { + "schema": { + "properties": { + "style": { + "default": "simple", + "const": "simple" + }, + "explode": { + "default": false, + "type": "boolean" + } + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$ref": "#/$defs/specification-extensions" + } + ], + "unevaluatedProperties": false + }, + "header-or-reference": { + "if": { + "type": "object", + "required": ["$ref"] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/header" + } + }, + "tag": { + "$comment": "https://spec.openapis.org/oas/v3.2#tag-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + }, + "parent": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": ["name"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "reference": { + "$comment": "https://spec.openapis.org/oas/v3.2#reference-object", + "type": "object", + "properties": { + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "schema": { + "$comment": "https://spec.openapis.org/oas/v3.2#schema-object", + "$dynamicAnchor": "meta", + "type": ["object", "boolean"] + }, + "security-scheme": { + "$comment": "https://spec.openapis.org/oas/v3.2#security-scheme-object", + "type": "object", + "properties": { + "type": { + "enum": ["apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"] + }, + "description": { + "type": "string" + }, + "deprecated": { + "default": false, + "type": "boolean" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-apikey" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-http" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-http-bearer" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-oauth2" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-oidc" + } + ], + "unevaluatedProperties": false, + "$defs": { + "type-apikey": { + "if": { + "properties": { + "type": { + "const": "apiKey" + } + } + }, + "then": { + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": ["query", "header", "cookie"] + } + }, + "required": ["name", "in"] + } + }, + "type-http": { + "if": { + "properties": { + "type": { + "const": "http" + } + } + }, + "then": { + "properties": { + "scheme": { + "type": "string" + } + }, + "required": ["scheme"] + } + }, + "type-http-bearer": { + "if": { + "properties": { + "type": { + "const": "http" + }, + "scheme": { + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" + } + }, + "required": ["type", "scheme"] + }, + "then": { + "properties": { + "bearerFormat": { + "type": "string" + } + } + } + }, + "type-oauth2": { + "if": { + "properties": { + "type": { + "const": "oauth2" + } + } + }, + "then": { + "properties": { + "flows": { + "$ref": "#/$defs/oauth-flows" + }, + "oauth2MetadataUrl": { + "type": "string", + "format": "uri-reference" + } + }, + "required": ["flows"] + } + }, + "type-oidc": { + "if": { + "properties": { + "type": { + "const": "openIdConnect" + } + } + }, + "then": { + "properties": { + "openIdConnectUrl": { + "type": "string", + "format": "uri-reference" + } + }, + "required": ["openIdConnectUrl"] + } + } + } + }, + "security-scheme-or-reference": { + "if": { + "type": "object", + "required": ["$ref"] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/security-scheme" + } + }, + "oauth-flows": { + "type": "object", + "properties": { + "implicit": { + "$ref": "#/$defs/oauth-flows/$defs/implicit" + }, + "password": { + "$ref": "#/$defs/oauth-flows/$defs/password" + }, + "clientCredentials": { + "$ref": "#/$defs/oauth-flows/$defs/client-credentials" + }, + "authorizationCode": { + "$ref": "#/$defs/oauth-flows/$defs/authorization-code" + }, + "deviceAuthorization": { + "$ref": "#/$defs/oauth-flows/$defs/device-authorization" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "implicit": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": ["authorizationUrl", "scopes"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "password": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": ["tokenUrl", "scopes"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "client-credentials": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": ["tokenUrl", "scopes"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "authorization-code": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": ["authorizationUrl", "tokenUrl", "scopes"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "device-authorization": { + "type": "object", + "properties": { + "deviceAuthorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": ["deviceAuthorizationUrl", "tokenUrl", "scopes"], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + } + } + }, + "security-requirement": { + "$comment": "https://spec.openapis.org/oas/v3.2#security-requirement-object", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "specification-extensions": { + "$comment": "https://spec.openapis.org/oas/v3.2#specification-extensions", + "patternProperties": { + "^x-": true + } + }, + "examples": { + "properties": { + "example": true, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + } + }, + "not": { + "required": ["example", "examples"] + } + }, + "map-of-strings": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "explode-for-form": { + "$comment": "for encoding objects, and query and cookie parameters, style=form is the default", + "if": { + "properties": { + "style": { + "const": "form" + } + } + }, + "then": { + "properties": { + "explode": { + "default": true + } + } + }, + "else": { + "properties": { + "explode": { + "default": false + } + } + } + } + } +} diff --git a/testdata/cases/primitives/check.odin b/testdata/cases/primitives/check.odin @@ -0,0 +1,20 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, root.count, 7) + testing.expect_value(t, root.ratio, 0.5) + testing.expect_value(t, root.active, true) + testing.expect_value(t, root.label, "x") + arr, is_array := root.anything.(json.Array) + testing.expect(t, is_array, "anything should hold a json.Array") + testing.expect_value(t, len(arr), 3) +} diff --git a/testdata/cases/primitives/expected.odin b/testdata/cases/primitives/expected.odin @@ -0,0 +1,12 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +import "core:encoding/json" + +Root :: struct { + count: i64 `json:"count"`, + ratio: f64 `json:"ratio"`, + active: bool `json:"active"`, + label: string `json:"label"`, + anything: json.Value `json:"anything"`, +} diff --git a/testdata/cases/primitives/sample.json b/testdata/cases/primitives/sample.json @@ -0,0 +1,7 @@ +{ + "count": 7, + "ratio": 0.5, + "active": true, + "label": "x", + "anything": [1, "two", null] +} diff --git a/testdata/cases/primitives/schema.json b/testdata/cases/primitives/schema.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "count": { "type": "integer" }, + "ratio": { "type": "number" }, + "active": { "type": "boolean" }, + "label": { "type": "string" }, + "anything": {} + }, + "required": ["count", "ratio", "active", "label", "anything"] +} diff --git a/testdata/cases/recursive/check.odin b/testdata/cases/recursive/check.odin @@ -0,0 +1,24 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + tree := root.tree.? or_else Node{} + testing.expect_value(t, tree.value, "root") + children := tree.children.? or_else nil + testing.expect_value(t, len(children), 2) + testing.expect_value(t, children[0].value, "leaf-a") + grandchildren := children[1].children.? or_else nil + testing.expect_value(t, len(grandchildren), 1) + testing.expect_value(t, grandchildren[0].value, "leaf-b") + loop := root.loop.? or_else Loop{} + _, next_is_object := loop.next.(json.Object) + testing.expect(t, next_is_object, "loop.next should hold a raw json.Object") +} diff --git a/testdata/cases/recursive/expected.odin b/testdata/cases/recursive/expected.odin @@ -0,0 +1,18 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +import "core:encoding/json" + +Root :: struct { + tree: Maybe(Node) `json:"tree"`, + loop: Maybe(Loop) `json:"loop"`, +} + +Node :: struct { + value: string `json:"value"`, + children: Maybe([]Node) `json:"children"`, +} + +Loop :: struct { + next: json.Value `json:"next"`, +} diff --git a/testdata/cases/recursive/sample.json b/testdata/cases/recursive/sample.json @@ -0,0 +1,10 @@ +{ + "tree": { + "value": "root", + "children": [ + { "value": "leaf-a" }, + { "value": "branch", "children": [{ "value": "leaf-b" }] } + ] + }, + "loop": { "next": { "next": {} } } +} diff --git a/testdata/cases/recursive/schema.json b/testdata/cases/recursive/schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "tree": { "$ref": "#/$defs/node" }, + "loop": { "$ref": "#/$defs/loop" } + }, + "$defs": { + "node": { + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": { "$ref": "#/$defs/node" } + } + }, + "required": ["value"] + }, + "loop": { + "type": "object", + "properties": { + "next": { "$ref": "#/$defs/loop" } + } + } + } +} diff --git a/testdata/cases/refs/check.odin b/testdata/cases/refs/check.odin @@ -0,0 +1,18 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, root.owner.name, "grug") + owner_pet := root.owner.pet.? or_else Pet{} + testing.expect_value(t, owner_pet.name, "rex") + pet := root.pet.? or_else Pet{} + testing.expect_value(t, pet.name, "spot") +} diff --git a/testdata/cases/refs/expected.odin b/testdata/cases/refs/expected.odin @@ -0,0 +1,16 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + owner: Person `json:"owner"`, + pet: Maybe(Pet) `json:"pet"`, +} + +Person :: struct { + name: string `json:"name"`, + pet: Maybe(Pet) `json:"pet"`, +} + +Pet :: struct { + name: string `json:"name"`, +} diff --git a/testdata/cases/refs/sample.json b/testdata/cases/refs/sample.json @@ -0,0 +1,4 @@ +{ + "owner": { "name": "grug", "pet": { "name": "rex" } }, + "pet": { "name": "spot" } +} diff --git a/testdata/cases/refs/schema.json b/testdata/cases/refs/schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "owner": { "$ref": "#/$defs/person" }, + "pet": { "$ref": "#/$defs/pet" } + }, + "required": ["owner"], + "$defs": { + "person": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "pet": { "$ref": "#/$defs/pet" } + }, + "required": ["name"] + }, + "pet": { + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] + } + } +} diff --git a/testdata/cases/required/check.odin b/testdata/cases/required/check.odin @@ -0,0 +1,17 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, root.id, 42) + testing.expect_value(t, root.name, "grug") + _, has_nickname := root.nickname.? + testing.expect_value(t, has_nickname, false) +} diff --git a/testdata/cases/required/expected.odin b/testdata/cases/required/expected.odin @@ -0,0 +1,8 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + id: i64 `json:"id"`, + name: string `json:"name"`, + nickname: Maybe(string) `json:"nickname"`, +} diff --git a/testdata/cases/required/sample.json b/testdata/cases/required/sample.json @@ -0,0 +1 @@ +{ "id": 42, "name": "grug" } diff --git a/testdata/cases/required/schema.json b/testdata/cases/required/schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "nickname": { "type": "string" } + }, + "required": ["id", "name"] +} diff --git a/testdata/cases/simple/check.odin b/testdata/cases/simple/check.odin @@ -0,0 +1,20 @@ +package check + +import "core:encoding/json" +import "core:testing" + +SAMPLE :: #load("sample.json", string) + +@(test) +parses :: proc(t: ^testing.T) { + root: Root + err := json.unmarshal_string(SAMPLE, &root) + testing.expectf(t, err == nil, "unmarshal failed: %v", err) + testing.expect_value(t, root.one.? or_else "", "hello") + testing.expect_value(t, root.two.? or_else 0, 4.5) + three := root.three.? or_else nil + testing.expect_value(t, len(three), 2) + testing.expect_value(t, three[0], "a") + four := root.four.? or_else Root_Four{} + testing.expect_value(t, four.foobar.? or_else "", "baz") +} diff --git a/testdata/cases/simple/expected.odin b/testdata/cases/simple/expected.odin @@ -0,0 +1,13 @@ +// Code generated by jschema. DO NOT EDIT. +package schema + +Root :: struct { + one: Maybe(string) `json:"one"`, + two: Maybe(f64) `json:"two"`, + three: Maybe([]string) `json:"three"`, + four: Maybe(Root_Four) `json:"four"`, +} + +Root_Four :: struct { + foobar: Maybe(string) `json:"foobar"`, +} diff --git a/testdata/cases/simple/sample.json b/testdata/cases/simple/sample.json @@ -0,0 +1,6 @@ +{ + "one": "hello", + "two": 4.5, + "three": ["a", "b"], + "four": { "foobar": "baz" } +} diff --git a/testdata/cases/simple/schema.json b/testdata/cases/simple/schema.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "properties": { + "one": { + "type": "string" + }, + "two": { + "type": "number" + }, + "three": { + "type": "array", + "items": { "type": "string" } + }, + "four": { + "type": "object", + "properties": { + "foobar": { + "type": "string" + } + } + } + } +}