diff --git a/CLAUDE.md b/CLAUDE.md index 33094d46..a8e117bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -554,7 +554,7 @@ Typed arrays: `let nums: array = [1, 2, 3];` --- -## Standard Library (42 modules) +## Standard Library (43 modules) Import with `@stdlib/` prefix: ```hemlock @@ -586,6 +586,7 @@ import { TcpStream, UdpSocket } from "@stdlib/net"; | `ipc` | Inter-process communication | | `iter` | Iterator utilities | | `json` | parse, stringify, pretty, get, set | +| `json_schema` | validate, is_valid, schema builders | | `logging` | Logger with levels | | `math` | sin, cos, sqrt, pow, rand, PI, E | | `net` | TcpListener, TcpStream, UdpSocket | @@ -979,7 +980,7 @@ make parity - Manual memory management with `talloc()` and `sizeof()` - Async/await with true pthread parallelism - Atomic operations for lock-free concurrent programming -- 42 stdlib modules (+ arena, assert, semver, toml, retry, iter, random, shell, termios, vector) +- 43 stdlib modules (+ arena, assert, semver, toml, retry, iter, random, shell, termios, vector, json_schema) - FFI for C interop with `export extern fn` for reusable library wrappers - FFI struct support in compiler (pass C structs by value) - FFI pointer helpers (`ptr_null`, `ptr_read_*`, `ptr_write_*`) diff --git a/stdlib/README.md b/stdlib/README.md index ef6f6144..98606615 100644 --- a/stdlib/README.md +++ b/stdlib/README.md @@ -174,6 +174,21 @@ Full-featured JSON manipulation library: See [docs/json.md](docs/json.md) for detailed documentation. +### JSON Schema (`@stdlib/json_schema`) +**Status:** Complete + +JSON Schema validation for structured output (pure Hemlock, built on `@stdlib/json`): +- **Core:** validate, is_valid, validate_json +- **Schema keywords:** type, enum, const, required, properties, items, additionalProperties +- **Number constraints:** minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf +- **String constraints:** minLength, maxLength +- **Array constraints:** minItems, maxItems, uniqueItems +- **Composition:** allOf, anyOf, oneOf, not, if/then/else +- **Builders:** string_type, number_type, integer_type, boolean_type, null_type, array_type, object_type, nullable, enum_type, const_type, any_of, one_of, all_of, not_schema +- **Use cases:** LLM structured output validation, API response checking, config validation + +See [docs/json_schema.md](docs/json_schema.md) for detailed documentation. + ### Strings (`@stdlib/strings`) **Status:** Complete @@ -502,6 +517,7 @@ stdlib/ ├── websocket.hml # WebSocket client/server (via libwebsockets FFI) ├── websocket_pure.hml # WebSocket pure Hemlock implementation (educational) ├── json.hml # JSON module (pure Hemlock) +├── json_schema.hml # JSON Schema validation (pure Hemlock) ├── strings.hml # String utilities module (pure Hemlock) ├── encoding.hml # Encoding module (pure Hemlock) ├── testing.hml # Testing framework (pure Hemlock) @@ -519,6 +535,7 @@ stdlib/ ├── http.md # HTTP API reference ├── websocket.md # WebSocket API reference ├── json.md # JSON API reference + ├── json_schema.md # JSON Schema API reference ├── strings.md # Strings API reference ├── encoding.md # Encoding API reference └── testing.md # Testing API reference @@ -564,6 +581,7 @@ See `STDLIB_ANALYSIS_UPDATED.md` and `STDLIB_NETWORKING_DESIGN.md` for detailed | http | ✅ Production (libwebsockets) | ✅ Complete | ✅ Good | 280 | High | | websocket | ✅ Production (libwebsockets) | ✅ Complete | ✅ Good | 318 | High | | json | ✅ Comprehensive | ✅ Complete | ✅ Good | 550+ | High | +| json_schema | ✅ Complete | ✅ Complete | ✅ Comprehensive | 400+ | High | | strings | ✅ Complete | ✅ Complete | ✅ Comprehensive | 293 | High | | encoding | ✅ Complete | ✅ Complete | ✅ Comprehensive | 370 | High | | testing | ✅ Complete | ✅ Complete | ✅ Good | 410 | High | diff --git a/stdlib/docs/json.md b/stdlib/docs/json.md index ec5cb58a..31c828f2 100644 --- a/stdlib/docs/json.md +++ b/stdlib/docs/json.md @@ -649,14 +649,14 @@ try { 1. **No object iteration builtin** - `merge()`, `patch()`, and object `equals()` not yet implemented 2. **No line numbers in parse errors** - Validation doesn't report exact error location -3. **No JSON Schema** - Schema validation not yet supported +3. **JSON Schema** - See `@stdlib/json_schema` for schema validation 4. **No streaming** - Large files must fit in memory ## Future Enhancements Planned additions: - Object iteration support (enables merge/patch/equals) -- JSON Schema validation +- ~~JSON Schema validation~~ → See `@stdlib/json_schema` - JSON Patch (RFC 6902) - JSON Pointer (RFC 6901) - Streaming parser for large files @@ -667,4 +667,5 @@ Planned additions: - Built-in `serialize()` method (CLAUDE.md - Objects section) - Built-in `deserialize()` method (CLAUDE.md - Strings section) - `@stdlib/fs` - File operations +- `@stdlib/json_schema` - JSON Schema validation - `@stdlib/http` - HTTP requests with JSON diff --git a/stdlib/docs/json_schema.md b/stdlib/docs/json_schema.md new file mode 100644 index 00000000..61a00d30 --- /dev/null +++ b/stdlib/docs/json_schema.md @@ -0,0 +1,611 @@ +# JSON Schema Module Documentation + +Validates that values match expected shapes defined as JSON Schema objects. Built as a pure Hemlock module on top of `@stdlib/json`. Useful for validating LLM structured output, API responses, configuration files, and user input. + +Supports a practical subset of JSON Schema Draft 7: type checking, number/string/array/object constraints, composition (`allOf`/`anyOf`/`oneOf`/`not`), conditional schemas (`if`/`then`/`else`), `enum`, and `const`. + +## Import + +```hemlock +import { validate, is_valid } from "@stdlib/json_schema"; + +// Builder helpers +import { + string_type, number_type, integer_type, boolean_type, null_type, + array_type, object_type, nullable, enum_type, const_type, + any_of, one_of, all_of, not_schema +} from "@stdlib/json_schema"; + +// Utilities +import { validate_json, format_errors } from "@stdlib/json_schema"; +``` + +## Quick Start + +```hemlock +import { validate, is_valid, object_type, string_type, integer_type } from "@stdlib/json_schema"; + +// Define a schema +let user_schema = object_type( + { + name: string_type({ minLength: 1 }), + age: integer_type({ minimum: 0, maximum: 150 }) + }, + ["name", "age"] // required fields +); + +// Validate data +let user = { name: "Alice", age: 30 }; +assert(is_valid(user, user_schema)); // true + +// Get detailed errors +let bad = { name: "", age: -1 }; +let result = validate(bad, user_schema); +// result.valid == false +// result.errors contains details about each violation +``` + +## API Reference + +### Core Functions + +#### `validate(value, schema)` + +Validate a value against a JSON Schema. Returns a result object with all errors. + +```hemlock +let schema = { type: "string", minLength: 3 }; +let result = validate("hi", schema); +print(result.valid); // false +print(result.errors.length); // 1 +print(result.errors[0].path); // "" +print(result.errors[0].message); // "string length 2 < minLength 3" +``` + +**Parameters:** +- `value` - Any Hemlock value to validate +- `schema` - JSON Schema object (or boolean) + +**Returns:** `{ valid: bool, errors: array }` + +Each error object has: +- `path` (string) - Dot-notation path to the failing value (e.g., `"users[1].name"`) +- `message` (string) - Human-readable description of the error +- `schema_path` (string) - Path within the schema that triggered the error + +--- + +#### `is_valid(value, schema): bool` + +Check if a value matches a schema. Returns only a boolean, no error details. + +```hemlock +if (is_valid(data, schema)) { + process(data); +} else { + print("Invalid data"); +} +``` + +--- + +#### `validate_json(json_str: string, schema)` + +Parse a JSON string and validate the result against a schema. Returns a parse error if the string is not valid JSON. + +```hemlock +let result = validate_json("{\"name\":\"Alice\"}", schema); +if (!result.valid) { + for (err in result.errors) { + print(err.message); + } +} + +// Invalid JSON +let bad = validate_json("{bad json", schema); +// bad.errors[0].message contains "invalid JSON: ..." +``` + +--- + +#### `format_errors(errors: array): string` + +Format an array of error objects into a human-readable multi-line string. + +```hemlock +let result = validate(data, schema); +if (!result.valid) { + print(format_errors(result.errors)); +} +// Output: +// name: expected type "string", got integer +// age: value -1 < minimum 0 +``` + +--- + +### Schema Builder Helpers + +Builder functions create schema objects with less boilerplate. They return plain objects that can be further modified. + +#### `string_type(opts?)` + +```hemlock +string_type() // { type: "string" } +string_type({ minLength: 1 }) // { type: "string", minLength: 1 } +string_type({ maxLength: 100 }) // { type: "string", maxLength: 100 } +string_type({ minLength: 3, maxLength: 50 }) +``` + +**Options:** `minLength`, `maxLength`, `enum` + +--- + +#### `number_type(opts?)` + +```hemlock +number_type() // { type: "number" } +number_type({ minimum: 0 }) // { type: "number", minimum: 0 } +number_type({ minimum: 0, maximum: 1 }) // range [0, 1] +``` + +**Options:** `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` + +--- + +#### `integer_type(opts?)` + +Same as `number_type` but requires integer values (no fractional part). + +```hemlock +integer_type() // { type: "integer" } +integer_type({ minimum: 1 }) // positive integers +``` + +--- + +#### `boolean_type()` + +```hemlock +boolean_type() // { type: "boolean" } +``` + +--- + +#### `null_type()` + +```hemlock +null_type() // { type: "null" } +``` + +--- + +#### `array_type(item_schema?, opts?)` + +```hemlock +array_type() // { type: "array" } +array_type(string_type()) // array of strings +array_type(integer_type(), { minItems: 1 }) // non-empty array of integers +array_type(null, { maxItems: 10 }) // any array, max 10 items +array_type(string_type(), { uniqueItems: true }) // unique strings +``` + +**Options:** `minItems`, `maxItems`, `uniqueItems` + +--- + +#### `object_type(properties?, required?, opts?)` + +```hemlock +// Simple object +object_type() + +// Object with typed properties +object_type({ + name: string_type(), + age: integer_type() +}) + +// With required fields +object_type( + { name: string_type(), age: integer_type() }, + ["name"] +) + +// Strict (no extra properties allowed) +object_type( + { x: number_type(), y: number_type() }, + ["x", "y"], + { additional: false } +) +``` + +**Options:** `additional` (or `additionalProperties`), `minProperties`, `maxProperties` + +--- + +#### `nullable(schema)` + +Wraps a schema to also accept `null`. + +```hemlock +nullable(string_type()) // accepts string or null +nullable(integer_type()) // accepts integer or null +``` + +--- + +#### `enum_type(values: array)` + +Value must be one of the listed values. + +```hemlock +enum_type(["red", "green", "blue"]) +enum_type([1, 2, 3]) +enum_type(["active", "inactive", null]) +``` + +--- + +#### `const_type(value)` + +Value must exactly equal the given value. + +```hemlock +const_type("fixed") +const_type(42) +const_type(true) +``` + +--- + +#### `any_of(schemas: array)` + +Value must match at least one of the given schemas. + +```hemlock +any_of([string_type(), integer_type()]) +any_of([ + object_type({ kind: const_type("a") }, ["kind"]), + object_type({ kind: const_type("b") }, ["kind"]) +]) +``` + +--- + +#### `one_of(schemas: array)` + +Value must match exactly one of the given schemas. + +```hemlock +one_of([ + { type: "integer", minimum: 0 }, + { type: "string", minLength: 1 } +]) +``` + +--- + +#### `all_of(schemas: array)` + +Value must match all of the given schemas. + +```hemlock +all_of([ + { type: "object", required: ["name"] }, + { type: "object", required: ["email"] } +]) +``` + +--- + +#### `not_schema(schema)` + +Value must NOT match the given schema. + +```hemlock +not_schema(null_type()) // anything except null +not_schema(string_type()) // anything except string +``` + +--- + +## Schema Language Reference + +Schemas are plain Hemlock objects. You can use builder helpers or write them directly. + +### Type Checking + +```hemlock +{ type: "string" } +{ type: "number" } // any numeric type +{ type: "integer" } // integers only (no fractional part) +{ type: "boolean" } +{ type: "null" } +{ type: "array" } +{ type: "object" } + +// Union types +{ type: ["string", "null"] } // nullable string +{ type: ["string", "number"] } // string or number +``` + +### Number Constraints + +```hemlock +{ type: "number", minimum: 0 } // >= 0 +{ type: "number", maximum: 100 } // <= 100 +{ type: "number", exclusiveMinimum: 0 } // > 0 +{ type: "number", exclusiveMaximum: 100 } // < 100 +{ type: "integer", multipleOf: 5 } // 0, 5, 10, ... +{ type: "number", minimum: 0, maximum: 1 } // range [0, 1] +``` + +### String Constraints + +```hemlock +{ type: "string", minLength: 1 } // non-empty +{ type: "string", maxLength: 255 } // bounded +{ type: "string", minLength: 3, maxLength: 50 } // range +``` + +### Array Constraints + +```hemlock +{ type: "array", items: { type: "string" } } // array of strings +{ type: "array", minItems: 1 } // non-empty +{ type: "array", maxItems: 10 } // bounded +{ type: "array", uniqueItems: true } // no duplicates +{ type: "array", items: { type: "integer" }, minItems: 1 } // combined +``` + +### Object Constraints + +```hemlock +// Required fields +{ type: "object", required: ["name", "email"] } + +// Property schemas +{ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer", minimum: 0 } + } +} + +// No extra properties allowed +{ + type: "object", + properties: { x: { type: "number" } }, + additionalProperties: false +} + +// Extra properties must match a schema +{ + type: "object", + properties: { name: { type: "string" } }, + additionalProperties: { type: "integer" } +} + +// Object size constraints +{ type: "object", minProperties: 1, maxProperties: 10 } +``` + +### Enum and Const + +```hemlock +// Must be one of listed values +{ enum: ["draft", "published", "archived"] } + +// Must be exactly this value +{ const: "v1" } +``` + +### Composition + +```hemlock +// Must match at least one schema +{ anyOf: [{ type: "string" }, { type: "null" }] } + +// Must match exactly one schema +{ oneOf: [{ type: "string" }, { type: "integer" }] } + +// Must match all schemas +{ allOf: [ + { required: ["name"] }, + { required: ["email"] } +]} + +// Must NOT match the schema +{ not: { type: "null" } } +``` + +### Conditional (if/then/else) + +Note: `if`, `then`, `else` are reserved keywords in Hemlock, so schemas using these must be built via `parse()` from `@stdlib/json`. + +```hemlock +import { parse } from "@stdlib/json"; + +let schema = parse("{\"if\":{\"properties\":{\"kind\":{\"const\":\"email\"}},\"required\":[\"kind\"]},\"then\":{\"required\":[\"address\"]},\"else\":{\"required\":[\"phone\"]}}"); +``` + +### Boolean Schemas + +```hemlock +true // allows any value +false // rejects all values +``` + +--- + +## Use Cases + +### Validating LLM Structured Output + +```hemlock +import { validate, format_errors, object_type, string_type, integer_type, array_type, enum_type } from "@stdlib/json_schema"; +import { parse } from "@stdlib/json"; + +// Define expected response shape +let response_schema = object_type( + { + model: string_type(), + choices: array_type( + object_type( + { + index: integer_type({ minimum: 0 }), + message: object_type( + { + role: enum_type(["assistant", "system", "user"]), + content: string_type({ minLength: 1 }) + }, + ["role", "content"] + ), + finish_reason: enum_type(["stop", "length", "content_filter"]) + }, + ["index", "message", "finish_reason"] + ), + { minItems: 1 } + ) + }, + ["model", "choices"] +); + +// Validate LLM response +let response_json = get_llm_response(); +let result = validate(parse(response_json), response_schema); + +if (!result.valid) { + print("LLM response doesn't match expected shape:"); + print(format_errors(result.errors)); +} +``` + +### Validating Tool Call Arguments + +```hemlock +import { validate_json, object_type, string_type, number_type, nullable } from "@stdlib/json_schema"; + +let search_args_schema = object_type( + { + query: string_type({ minLength: 1, maxLength: 500 }), + max_results: integer_type({ minimum: 1, maximum: 100 }), + language: nullable(string_type()) + }, + ["query"] +); + +fn handle_tool_call(name: string, args_json: string) { + if (name == "search") { + let result = validate_json(args_json, search_args_schema); + if (!result.valid) { + throw "Invalid search arguments: " + format_errors(result.errors); + } + let args = parse(args_json); + // ... perform search + } +} +``` + +### Validating Configuration + +```hemlock +import { validate, format_errors, object_type, string_type, integer_type, boolean_type, enum_type } from "@stdlib/json_schema"; +import { parse_file } from "@stdlib/json"; + +let config_schema = object_type( + { + server: object_type( + { + host: string_type(), + port: integer_type({ minimum: 1, maximum: 65535 }) + }, + ["host", "port"] + ), + database: object_type( + { + url: string_type({ minLength: 1 }), + pool_size: integer_type({ minimum: 1, maximum: 100 }) + }, + ["url"] + ), + log_level: enum_type(["debug", "info", "warn", "error"]) + }, + ["server"] +); + +let config = parse_file("config.json"); +let result = validate(config, config_schema); +if (!result.valid) { + eprint("Configuration errors:"); + eprint(format_errors(result.errors)); +} +``` + +--- + +## Error Path Reporting + +Errors include dot-notation paths showing exactly where validation failed: + +```hemlock +let schema = object_type({ + users: array_type(object_type( + { name: string_type(), age: integer_type() }, + ["name"] + )) +}); + +let data = { users: [{ name: "Alice" }, { age: 30 }, { name: 42 }] }; +let result = validate(data, schema); +// Errors: +// users[1]: missing required property "name" +// users[2].name: expected type "string", got integer +``` + +--- + +## Supported Schema Keywords + +| Keyword | Applies to | Description | +|---------|-----------|-------------| +| `type` | any | Type or array of types | +| `enum` | any | Value must be one of listed values | +| `const` | any | Value must exactly equal | +| `minimum` | number | `>=` bound | +| `maximum` | number | `<=` bound | +| `exclusiveMinimum` | number | `>` bound | +| `exclusiveMaximum` | number | `<` bound | +| `multipleOf` | number | Must be divisible by | +| `minLength` | string | Minimum character count | +| `maxLength` | string | Maximum character count | +| `items` | array | Schema for each element | +| `minItems` | array | Minimum element count | +| `maxItems` | array | Maximum element count | +| `uniqueItems` | array | No duplicate elements | +| `properties` | object | Schema per property | +| `required` | object | Required property names | +| `additionalProperties` | object | `false` or schema for extra properties | +| `minProperties` | object | Minimum property count | +| `maxProperties` | object | Maximum property count | +| `allOf` | any | Must match all schemas | +| `anyOf` | any | Must match at least one | +| `oneOf` | any | Must match exactly one | +| `not` | any | Must NOT match | +| `if`/`then`/`else` | any | Conditional validation | + +## Not Supported + +- `$ref` / `$id` / `$defs` (schema references) +- `pattern` (string regex - would require `@stdlib/regex`) +- `format` (semantic string formats like "email", "uri") +- `contains` / `prefixItems` (array keywords) +- `patternProperties` / `propertyNames` (object keywords) +- `dependentRequired` / `dependentSchemas` + +## See Also + +- `@stdlib/json` - JSON parsing, serialization, path access +- JSON Schema specification: https://json-schema.org/ diff --git a/stdlib/json_schema.hml b/stdlib/json_schema.hml new file mode 100644 index 00000000..c9f5fcf2 --- /dev/null +++ b/stdlib/json_schema.hml @@ -0,0 +1,739 @@ +// @stdlib/json_schema - JSON Schema validation for structured output +// +// Validates that values (e.g., parsed LLM responses) match expected shapes +// defined as JSON Schema objects (subset of JSON Schema Draft 7). +// +// Usage: +// import { validate, is_valid, ValidationError } from "@stdlib/json_schema"; +// +// let schema = { +// type: "object", +// properties: { +// name: { type: "string" }, +// age: { type: "integer", minimum: 0 } +// }, +// required: ["name"] +// }; +// +// let result = validate({ name: "Alice", age: 30 }, schema); +// if (!result.valid) { +// print("Errors: " + result.errors.length); +// } + +import { parse, stringify } from "@stdlib/json"; + +// ============================================================================ +// Internal: Type checking helpers +// ============================================================================ + +fn is_json_number(value): bool { + let t = typeof(value); + return t == "i8" || t == "i16" || t == "i32" || t == "i64" || + t == "u8" || t == "u16" || t == "u32" || t == "u64" || + t == "f32" || t == "f64" || + t == "integer" || t == "number"; +} + +fn is_json_integer(value): bool { + if (!is_json_number(value)) { + return false; + } + let t = typeof(value); + // Float types need a truncation check + if (t == "f32" || t == "f64" || t == "number") { + return value == i64(value); + } + return true; +} + +fn is_json_object(value): bool { + return typeof(value) == "object"; +} + +fn is_json_array(value): bool { + return typeof(value) == "array"; +} + +fn is_json_string(value): bool { + return typeof(value) == "string"; +} + +fn is_json_bool(value): bool { + return typeof(value) == "bool"; +} + +fn is_json_null(value): bool { + return typeof(value) == "null"; +} + +// Format a value for error messages (truncated for readability) +fn format_val(value): string { + let t = typeof(value); + if (t == "null") { return "null"; } + if (t == "bool") { if (value) { return "true"; } else { return "false"; } } + if (t == "string") { + if (value.length > 50) { + return "\"" + value.substr(0, 50) + "...\""; + } + return "\"" + value + "\""; + } + if (is_json_number(value)) { return "" + value; } + if (t == "array") { return "array(" + value.length + ")"; } + if (t == "object") { return "object"; } + return "<" + t + ">"; +} + +// ============================================================================ +// Validation result +// ============================================================================ + +// Create a validation result object +fn make_result(valid: bool, errors: array) { + return { valid: valid, errors: errors }; +} + +// Create a single error entry +fn make_error(path: string, message: string, schema_path: string) { + return { path: path, message: message, schema_path: schema_path }; +} + +// Join path segments +fn join_path(base: string, key: string): string { + if (base == "") { + return key; + } + return base + "." + key; +} + +fn join_path_index(base: string, index: i32): string { + if (base == "") { + return "[" + index + "]"; + } + return base + "[" + index + "]"; +} + +// ============================================================================ +// Core validation engine +// ============================================================================ + +// Validate a value against a schema, collecting errors at the given path. +// Returns an array of error objects. +fn validate_impl(value, schema, path: string, schema_path: string): array { + if (!is_json_object(schema)) { + // A boolean schema: true allows everything, false rejects everything + if (typeof(schema) == "bool") { + if (schema) { + return []; + } else { + return [make_error(path, "value is not allowed here", schema_path)]; + } + } + return [make_error(path, "schema must be an object or boolean", schema_path)]; + } + + let errors = []; + + // --- const --- + if (schema.has("const")) { + let expected = schema["const"]; + if (!deep_equals(value, expected)) { + errors.push(make_error(path, "expected const value " + format_val(expected) + ", got " + format_val(value), join_path(schema_path, "const"))); + } + } + + // --- enum --- + if (schema.has("enum")) { + let enum_vals = schema["enum"]; + if (is_json_array(enum_vals)) { + let found = false; + for (let i = 0; i < enum_vals.length; i++) { + if (deep_equals(value, enum_vals[i])) { + found = true; + break; + } + } + if (!found) { + errors.push(make_error(path, "value " + format_val(value) + " not in enum", join_path(schema_path, "enum"))); + } + } + } + + // --- type --- + if (schema.has("type")) { + let type_spec = schema["type"]; + let type_ok = false; + + if (is_json_string(type_spec)) { + type_ok = check_type(value, type_spec); + } else if (is_json_array(type_spec)) { + // Union type: ["string", "null"] + for (let i = 0; i < type_spec.length; i++) { + if (check_type(value, type_spec[i])) { + type_ok = true; + break; + } + } + } + + if (!type_ok) { + errors.push(make_error(path, "expected type " + format_val(type_spec) + ", got " + json_type_name(value), join_path(schema_path, "type"))); + // Return early on type mismatch -- deeper checks would produce noise + return errors; + } + } + + // --- number constraints --- + if (is_json_number(value)) { + if (schema.has("minimum")) { + if (value < schema["minimum"]) { + errors.push(make_error(path, "value " + value + " < minimum " + schema["minimum"], join_path(schema_path, "minimum"))); + } + } + if (schema.has("maximum")) { + if (value > schema["maximum"]) { + errors.push(make_error(path, "value " + value + " > maximum " + schema["maximum"], join_path(schema_path, "maximum"))); + } + } + if (schema.has("exclusiveMinimum")) { + if (value <= schema["exclusiveMinimum"]) { + errors.push(make_error(path, "value " + value + " <= exclusiveMinimum " + schema["exclusiveMinimum"], join_path(schema_path, "exclusiveMinimum"))); + } + } + if (schema.has("exclusiveMaximum")) { + if (value >= schema["exclusiveMaximum"]) { + errors.push(make_error(path, "value " + value + " >= exclusiveMaximum " + schema["exclusiveMaximum"], join_path(schema_path, "exclusiveMaximum"))); + } + } + if (schema.has("multipleOf")) { + let divisor = schema["multipleOf"]; + if (divisor != 0) { + let remainder = value - (i64(value / divisor) * divisor); + if (remainder < 0) { remainder = -remainder; } + if (remainder > 0) { + errors.push(make_error(path, "value " + value + " is not a multiple of " + divisor, join_path(schema_path, "multipleOf"))); + } + } + } + } + + // --- string constraints --- + if (is_json_string(value)) { + if (schema.has("minLength")) { + if (value.length < schema["minLength"]) { + errors.push(make_error(path, "string length " + value.length + " < minLength " + schema["minLength"], join_path(schema_path, "minLength"))); + } + } + if (schema.has("maxLength")) { + if (value.length > schema["maxLength"]) { + errors.push(make_error(path, "string length " + value.length + " > maxLength " + schema["maxLength"], join_path(schema_path, "maxLength"))); + } + } + // pattern support would require @stdlib/regex -- skip for portability + } + + // --- array constraints --- + if (is_json_array(value)) { + if (schema.has("minItems")) { + if (value.length < schema["minItems"]) { + errors.push(make_error(path, "array length " + value.length + " < minItems " + schema["minItems"], join_path(schema_path, "minItems"))); + } + } + if (schema.has("maxItems")) { + if (value.length > schema["maxItems"]) { + errors.push(make_error(path, "array length " + value.length + " > maxItems " + schema["maxItems"], join_path(schema_path, "maxItems"))); + } + } + + // items - validate each element + if (schema.has("items")) { + let item_schema = schema["items"]; + for (let i = 0; i < value.length; i++) { + let item_errors = validate_impl(value[i], item_schema, join_path_index(path, i), join_path(schema_path, "items")); + for (let j = 0; j < item_errors.length; j++) { + errors.push(item_errors[j]); + } + } + } + + // uniqueItems + if (schema.has("uniqueItems") && schema["uniqueItems"] == true) { + for (let i = 0; i < value.length; i++) { + for (let j = i + 1; j < value.length; j++) { + if (deep_equals(value[i], value[j])) { + errors.push(make_error(path, "duplicate item at index " + i + " and " + j, join_path(schema_path, "uniqueItems"))); + break; + } + } + } + } + } + + // --- object constraints --- + if (is_json_object(value)) { + let obj_keys = value.keys(); + + // required + if (schema.has("required")) { + let req = schema["required"]; + if (is_json_array(req)) { + for (let i = 0; i < req.length; i++) { + let rkey = req[i]; + if (!value.has(rkey)) { + errors.push(make_error(path, "missing required property \"" + rkey + "\"", join_path(schema_path, "required"))); + } + } + } + } + + // properties + let validated_props = []; + if (schema.has("properties")) { + let props = schema["properties"]; + if (is_json_object(props)) { + let prop_keys = props.keys(); + for (let i = 0; i < prop_keys.length; i++) { + let pkey = prop_keys[i]; + validated_props.push(pkey); + if (value.has(pkey)) { + let prop_errors = validate_impl(value[pkey], props[pkey], join_path(path, pkey), join_path(schema_path, "properties." + pkey)); + for (let j = 0; j < prop_errors.length; j++) { + errors.push(prop_errors[j]); + } + } + } + } + } + + // additionalProperties + if (schema.has("additionalProperties")) { + let additional = schema["additionalProperties"]; + for (let i = 0; i < obj_keys.length; i++) { + let okey = obj_keys[i]; + // Check if this key was handled by properties + let was_validated = false; + for (let j = 0; j < validated_props.length; j++) { + if (validated_props[j] == okey) { + was_validated = true; + break; + } + } + if (!was_validated) { + if (typeof(additional) == "bool") { + if (!additional) { + errors.push(make_error(path, "unexpected additional property \"" + okey + "\"", join_path(schema_path, "additionalProperties"))); + } + } else if (is_json_object(additional)) { + // Schema for additional properties + let add_errors = validate_impl(value[okey], additional, join_path(path, okey), join_path(schema_path, "additionalProperties")); + for (let j = 0; j < add_errors.length; j++) { + errors.push(add_errors[j]); + } + } + } + } + } + + // minProperties / maxProperties + if (schema.has("minProperties")) { + if (obj_keys.length < schema["minProperties"]) { + errors.push(make_error(path, "object has " + obj_keys.length + " properties, minProperties is " + schema["minProperties"], join_path(schema_path, "minProperties"))); + } + } + if (schema.has("maxProperties")) { + if (obj_keys.length > schema["maxProperties"]) { + errors.push(make_error(path, "object has " + obj_keys.length + " properties, maxProperties is " + schema["maxProperties"], join_path(schema_path, "maxProperties"))); + } + } + } + + // --- Composition keywords --- + + // allOf - value must match ALL schemas + if (schema.has("allOf")) { + let all_schemas = schema["allOf"]; + if (is_json_array(all_schemas)) { + for (let i = 0; i < all_schemas.length; i++) { + let sub_errors = validate_impl(value, all_schemas[i], path, join_path(schema_path, "allOf[" + i + "]")); + for (let j = 0; j < sub_errors.length; j++) { + errors.push(sub_errors[j]); + } + } + } + } + + // anyOf - value must match at least one schema + if (schema.has("anyOf")) { + let any_schemas = schema["anyOf"]; + if (is_json_array(any_schemas)) { + let any_matched = false; + for (let i = 0; i < any_schemas.length; i++) { + let sub_errors = validate_impl(value, any_schemas[i], path, join_path(schema_path, "anyOf[" + i + "]")); + if (sub_errors.length == 0) { + any_matched = true; + break; + } + } + if (!any_matched) { + errors.push(make_error(path, "value does not match any schema in anyOf", join_path(schema_path, "anyOf"))); + } + } + } + + // oneOf - value must match exactly one schema + if (schema.has("oneOf")) { + let one_schemas = schema["oneOf"]; + if (is_json_array(one_schemas)) { + let match_count = 0; + for (let i = 0; i < one_schemas.length; i++) { + let sub_errors = validate_impl(value, one_schemas[i], path, join_path(schema_path, "oneOf[" + i + "]")); + if (sub_errors.length == 0) { + match_count++; + } + } + if (match_count == 0) { + errors.push(make_error(path, "value does not match any schema in oneOf", join_path(schema_path, "oneOf"))); + } else if (match_count > 1) { + errors.push(make_error(path, "value matches " + match_count + " schemas in oneOf, expected exactly 1", join_path(schema_path, "oneOf"))); + } + } + } + + // not - value must NOT match the schema + if (schema.has("not")) { + let not_schema = schema["not"]; + let sub_errors = validate_impl(value, not_schema, path, join_path(schema_path, "not")); + if (sub_errors.length == 0) { + errors.push(make_error(path, "value should not match schema in 'not'", join_path(schema_path, "not"))); + } + } + + // if/then/else - conditional schema + if (schema.has("if")) { + let if_schema = schema["if"]; + let if_errors = validate_impl(value, if_schema, path, join_path(schema_path, "if")); + if (if_errors.length == 0) { + // Condition matched - apply "then" + if (schema.has("then")) { + let then_errors = validate_impl(value, schema["then"], path, join_path(schema_path, "then")); + for (let i = 0; i < then_errors.length; i++) { + errors.push(then_errors[i]); + } + } + } else { + // Condition failed - apply "else" + if (schema.has("else")) { + let else_errors = validate_impl(value, schema["else"], path, join_path(schema_path, "else")); + for (let i = 0; i < else_errors.length; i++) { + errors.push(else_errors[i]); + } + } + } + } + + return errors; +} + +// Check if a value matches a JSON Schema type name +fn check_type(value, type_name: string): bool { + if (type_name == "string") { return is_json_string(value); } + if (type_name == "number") { return is_json_number(value); } + if (type_name == "integer") { return is_json_integer(value); } + if (type_name == "boolean") { return is_json_bool(value); } + if (type_name == "array") { return is_json_array(value); } + if (type_name == "object") { return is_json_object(value); } + if (type_name == "null") { return is_json_null(value); } + return false; +} + +// Get the JSON Schema type name for a value +fn json_type_name(value): string { + let t = typeof(value); + if (t == "null") { return "null"; } + if (t == "bool") { return "boolean"; } + if (t == "string") { return "string"; } + if (t == "array") { return "array"; } + if (t == "object") { return "object"; } + if (is_json_number(value)) { + if (is_json_integer(value)) { return "integer"; } + return "number"; + } + return t; +} + +// Deep equality comparison +fn deep_equals(a, b): bool { + let ta = typeof(a); + let tb = typeof(b); + + if (is_json_null(a) && is_json_null(b)) { return true; } + if (is_json_null(a) || is_json_null(b)) { return false; } + + // Numbers: compare by value regardless of specific numeric type + if (is_json_number(a) && is_json_number(b)) { + return a == b; + } + + if (ta != tb) { return false; } + + if (ta == "bool" || ta == "string") { return a == b; } + + if (ta == "array") { + if (a.length != b.length) { return false; } + for (let i = 0; i < a.length; i++) { + if (!deep_equals(a[i], b[i])) { return false; } + } + return true; + } + + if (ta == "object") { + let ka = a.keys(); + let kb = b.keys(); + if (ka.length != kb.length) { return false; } + for (let i = 0; i < ka.length; i++) { + let k = ka[i]; + if (!b.has(k)) { return false; } + if (!deep_equals(a[k], b[k])) { return false; } + } + return true; + } + + return a == b; +} + +// ============================================================================ +// Public API +// ============================================================================ + +// Validate a value against a JSON Schema. +// +// Returns: { valid: bool, errors: array } +// Each error: { path: string, message: string, schema_path: string } +// +// Example: +// let result = validate(data, schema); +// if (!result.valid) { +// for (err in result.errors) { +// print(err.path + ": " + err.message); +// } +// } +export fn validate(value, schema) { + let errors = validate_impl(value, schema, "", ""); + return make_result(errors.length == 0, errors); +} + +// Check if a value matches a schema (returns bool only). +// +// Example: +// if (is_valid(data, schema)) { print("OK"); } +export fn is_valid(value, schema): bool { + let errors = validate_impl(value, schema, "", ""); + return errors.length == 0; +} + +// Validate a JSON string against a schema. +// Parses the string first, then validates the parsed value. +// +// Returns: { valid: bool, errors: array } +// On parse failure, returns a single error about invalid JSON. +export fn validate_json(json_str: string, schema) { + try { + let value = parse(json_str); + return validate(value, schema); + } catch (e) { + return make_result(false, [make_error("", "invalid JSON: " + e, "")]); + } +} + +// ============================================================================ +// Schema builder helpers +// ============================================================================ + +// Build a schema for a string type +export fn string_type(opts?: null) { + let s = { type: "string" }; + if (opts != null) { + if (is_json_object(opts)) { + if (opts.has("minLength")) { s["minLength"] = opts["minLength"]; } + if (opts.has("maxLength")) { s["maxLength"] = opts["maxLength"]; } + if (opts.has("enum")) { s["enum"] = opts["enum"]; } + } + } + return s; +} + +// Build a schema for a number type +export fn number_type(opts?: null) { + let s = { type: "number" }; + if (opts != null) { + if (is_json_object(opts)) { + if (opts.has("minimum")) { s["minimum"] = opts["minimum"]; } + if (opts.has("maximum")) { s["maximum"] = opts["maximum"]; } + if (opts.has("exclusiveMinimum")) { s["exclusiveMinimum"] = opts["exclusiveMinimum"]; } + if (opts.has("exclusiveMaximum")) { s["exclusiveMaximum"] = opts["exclusiveMaximum"]; } + if (opts.has("multipleOf")) { s["multipleOf"] = opts["multipleOf"]; } + } + } + return s; +} + +// Build a schema for an integer type +export fn integer_type(opts?: null) { + let s = { type: "integer" }; + if (opts != null) { + if (is_json_object(opts)) { + if (opts.has("minimum")) { s["minimum"] = opts["minimum"]; } + if (opts.has("maximum")) { s["maximum"] = opts["maximum"]; } + if (opts.has("exclusiveMinimum")) { s["exclusiveMinimum"] = opts["exclusiveMinimum"]; } + if (opts.has("exclusiveMaximum")) { s["exclusiveMaximum"] = opts["exclusiveMaximum"]; } + if (opts.has("multipleOf")) { s["multipleOf"] = opts["multipleOf"]; } + } + } + return s; +} + +// Build a schema for a boolean type +export fn boolean_type() { + return { type: "boolean" }; +} + +// Build a schema for null type +export fn null_type() { + return { type: "null" }; +} + +// Build a schema for an array type +export fn array_type(item_schema?: null, opts?: null) { + let s = { type: "array" }; + if (item_schema != null) { + s["items"] = item_schema; + } + if (opts != null) { + if (is_json_object(opts)) { + if (opts.has("minItems")) { s["minItems"] = opts["minItems"]; } + if (opts.has("maxItems")) { s["maxItems"] = opts["maxItems"]; } + if (opts.has("uniqueItems")) { s["uniqueItems"] = opts["uniqueItems"]; } + } + } + return s; +} + +// Build a schema for an object type +// +// Example: +// let schema = object_type( +// { +// name: string_type(), +// age: integer_type({ minimum: 0 }) +// }, +// ["name"], // required +// { additional: false } // options +// ); +export fn object_type(properties?: null, required?: null, opts?: null) { + let s = { type: "object" }; + if (properties != null) { + s["properties"] = properties; + } + if (required != null) { + s["required"] = required; + } + if (opts != null) { + if (is_json_object(opts)) { + if (opts.has("additional")) { s["additionalProperties"] = opts["additional"]; } + if (opts.has("additionalProperties")) { s["additionalProperties"] = opts["additionalProperties"]; } + if (opts.has("minProperties")) { s["minProperties"] = opts["minProperties"]; } + if (opts.has("maxProperties")) { s["maxProperties"] = opts["maxProperties"]; } + } + } + return s; +} + +// Create a nullable type (allows type or null) +// +// Example: +// let schema = nullable(string_type()); +// // Equivalent to { type: ["string", "null"] } +export fn nullable(schema) { + if (is_json_object(schema) && schema.has("type")) { + let t = schema["type"]; + if (is_json_string(t)) { + // Clone the schema and make type a union with null + let result = {}; + let keys = schema.keys(); + for (let i = 0; i < keys.length; i++) { + let k = keys[i]; + result[k] = schema[k]; + } + result["type"] = [t, "null"]; + return result; + } + } + // Fallback: use anyOf + return { anyOf: [schema, { type: "null" }] }; +} + +// Create an enum schema +// +// Example: +// let schema = enum_type(["red", "green", "blue"]); +export fn enum_type(values: array) { + return { enum: values }; +} + +// Create a const schema +// +// Example: +// let schema = const_type("fixed_value"); +export fn const_type(value) { + return { const: value }; +} + +// Create an anyOf schema (value must match at least one) +// +// Example: +// let schema = any_of([string_type(), integer_type()]); +export fn any_of(schemas: array) { + return { anyOf: schemas }; +} + +// Create a oneOf schema (value must match exactly one) +export fn one_of(schemas: array) { + return { oneOf: schemas }; +} + +// Create an allOf schema (value must match all) +export fn all_of(schemas: array) { + return { allOf: schemas }; +} + +// Create a not schema (value must NOT match) +export fn not_schema(schema) { + return { not: schema }; +} + +// ============================================================================ +// Error formatting +// ============================================================================ + +// Format validation errors as a human-readable string. +// +// Example: +// let result = validate(data, schema); +// if (!result.valid) { +// print(format_errors(result.errors)); +// } +export fn format_errors(errors: array): string { + if (errors.length == 0) { + return "no errors"; + } + let lines = []; + for (let i = 0; i < errors.length; i++) { + let err = errors[i]; + let line = ""; + if (err.path != "") { + line = err.path + ": "; + } + line = line + err.message; + lines.push(line); + } + return lines.join("\n"); +} diff --git a/tests/stdlib_json_schema/array_test.hml b/tests/stdlib_json_schema/array_test.hml new file mode 100644 index 00000000..bbe7c201 --- /dev/null +++ b/tests/stdlib_json_schema/array_test.hml @@ -0,0 +1,54 @@ +// Test @stdlib/json_schema - array validation + +import { validate, is_valid } from "@stdlib/json_schema"; + +// --- items schema --- +let items_schema = { + type: "array", + items: { type: "integer" } +}; +assert(is_valid([1, 2, 3], items_schema) == true, "items: all integers"); +assert(is_valid([], items_schema) == true, "items: empty array ok"); +assert(is_valid([1, "two", 3], items_schema) == false, "items: string in integer array"); + +// --- minItems --- +let min_items = { type: "array", minItems: 2 }; +assert(is_valid([1, 2], min_items) == true, "minItems: 2 elements ok"); +assert(is_valid([1, 2, 3], min_items) == true, "minItems: 3 elements ok"); +assert(is_valid([1], min_items) == false, "minItems: 1 element not enough"); +assert(is_valid([], min_items) == false, "minItems: empty not enough"); + +// --- maxItems --- +let max_items = { type: "array", maxItems: 3 }; +assert(is_valid([1, 2, 3], max_items) == true, "maxItems: 3 elements ok"); +assert(is_valid([1], max_items) == true, "maxItems: 1 element ok"); +assert(is_valid([1, 2, 3, 4], max_items) == false, "maxItems: 4 too many"); + +// --- uniqueItems --- +let unique_schema = { type: "array", uniqueItems: true }; +assert(is_valid([1, 2, 3], unique_schema) == true, "uniqueItems: all unique"); +assert(is_valid([], unique_schema) == true, "uniqueItems: empty ok"); +assert(is_valid([1, 2, 1], unique_schema) == false, "uniqueItems: duplicate 1"); +assert(is_valid(["a", "b", "a"], unique_schema) == false, "uniqueItems: duplicate string"); + +// --- nested array items --- +let nested_schema = { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" } + }, + required: ["name"] + } +}; +assert(is_valid([{ name: "Alice" }, { name: "Bob" }], nested_schema) == true, "nested: valid objects"); +assert(is_valid([{ name: "Alice" }, {}], nested_schema) == false, "nested: missing required"); + +// --- error paths for array items --- +let result = validate([1, "bad", 3], items_schema); +assert(result.valid == false, "array item error detected"); +assert(result.errors.length == 1, "one error for bad item"); +assert(result.errors[0].path.contains("[1]"), "error path includes index"); + +print("All array validation tests passed!"); diff --git a/tests/stdlib_json_schema/builder_test.hml b/tests/stdlib_json_schema/builder_test.hml new file mode 100644 index 00000000..e05a3f15 --- /dev/null +++ b/tests/stdlib_json_schema/builder_test.hml @@ -0,0 +1,144 @@ +// Test @stdlib/json_schema - schema builder helpers + +import { + validate, is_valid, + string_type, number_type, integer_type, boolean_type, null_type, + array_type, object_type, nullable, enum_type, const_type, + any_of, one_of, all_of, not_schema +} from "@stdlib/json_schema"; + +// --- string_type --- +let s1 = string_type(); +assert(is_valid("hello", s1) == true, "string_type: basic"); + +let s2 = string_type({ minLength: 3, maxLength: 10 }); +assert(is_valid("abc", s2) == true, "string_type: opts ok"); +assert(is_valid("ab", s2) == false, "string_type: too short"); + +// --- number_type --- +let n1 = number_type(); +assert(is_valid(3.14, n1) == true, "number_type: basic"); + +let n2 = number_type({ minimum: 0, maximum: 100 }); +assert(is_valid(50, n2) == true, "number_type: in range"); +assert(is_valid(-1, n2) == false, "number_type: below min"); + +// --- integer_type --- +let i1 = integer_type(); +assert(is_valid(42, i1) == true, "integer_type: basic"); +assert(is_valid(3.14, i1) == false, "integer_type: rejects float"); + +let i2 = integer_type({ minimum: 1, maximum: 10 }); +assert(is_valid(5, i2) == true, "integer_type: in range"); +assert(is_valid(0, i2) == false, "integer_type: below min"); + +// --- boolean_type --- +assert(is_valid(true, boolean_type()) == true, "boolean_type: true"); +assert(is_valid(42, boolean_type()) == false, "boolean_type: number"); + +// --- null_type --- +assert(is_valid(null, null_type()) == true, "null_type: null"); +assert(is_valid(0, null_type()) == false, "null_type: zero"); + +// --- array_type --- +let a1 = array_type(); +assert(is_valid([1, 2], a1) == true, "array_type: basic"); + +let a2 = array_type(string_type(), { minItems: 1, maxItems: 5 }); +assert(is_valid(["a", "b"], a2) == true, "array_type: string items"); +assert(is_valid([], a2) == false, "array_type: empty below min"); +assert(is_valid([1, 2], a2) == false, "array_type: wrong item type"); + +// --- object_type --- +let o1 = object_type( + { + name: string_type(), + age: integer_type({ minimum: 0 }) + }, + ["name"], + { additional: false } +); +assert(is_valid({ name: "Alice", age: 30 }, o1) == true, "object_type: valid"); +assert(is_valid({ name: "Alice" }, o1) == true, "object_type: without optional"); +assert(is_valid({}, o1) == false, "object_type: missing required"); +assert(is_valid({ name: "Alice", extra: true }, o1) == false, "object_type: extra prop"); + +// --- nullable --- +let ns = nullable(string_type()); +assert(is_valid("hello", ns) == true, "nullable: string ok"); +assert(is_valid(null, ns) == true, "nullable: null ok"); +assert(is_valid(42, ns) == false, "nullable: number rejected"); + +// --- enum_type --- +let e1 = enum_type(["a", "b", "c"]); +assert(is_valid("a", e1) == true, "enum_type: valid"); +assert(is_valid("d", e1) == false, "enum_type: invalid"); + +// --- const_type --- +let c1 = const_type(42); +assert(is_valid(42, c1) == true, "const_type: match"); +assert(is_valid(43, c1) == false, "const_type: no match"); + +// --- any_of --- +let ao = any_of([string_type(), integer_type()]); +assert(is_valid("hi", ao) == true, "any_of: string"); +assert(is_valid(42, ao) == true, "any_of: integer"); +assert(is_valid(true, ao) == false, "any_of: bool"); + +// --- not_schema --- +let ns2 = not_schema(string_type()); +assert(is_valid(42, ns2) == true, "not_schema: number ok"); +assert(is_valid("hi", ns2) == false, "not_schema: string rejected"); + +// --- complex LLM response schema --- +let llm_schema = object_type( + { + model: string_type(), + choices: array_type( + object_type( + { + index: integer_type({ minimum: 0 }), + message: object_type( + { + role: enum_type(["assistant", "system", "user"]), + content: string_type({ minLength: 1 }) + }, + ["role", "content"] + ), + finish_reason: enum_type(["stop", "length", "content_filter"]) + }, + ["index", "message", "finish_reason"] + ), + { minItems: 1 } + ) + }, + ["model", "choices"] +); + +let valid_response = { + model: "gpt-4", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Hello!" }, + finish_reason: "stop" + } + ] +}; + +assert(is_valid(valid_response, llm_schema) == true, "LLM response: valid"); + +let bad_response = { + model: "gpt-4", + choices: [ + { + index: 0, + message: { role: "unknown", content: "" }, + finish_reason: "stop" + } + ] +}; + +assert(is_valid(bad_response, llm_schema) == false, "LLM response: bad role + empty content"); + +print("All builder tests passed!"); diff --git a/tests/stdlib_json_schema/composition_test.hml b/tests/stdlib_json_schema/composition_test.hml new file mode 100644 index 00000000..9386a4cd --- /dev/null +++ b/tests/stdlib_json_schema/composition_test.hml @@ -0,0 +1,91 @@ +// Test @stdlib/json_schema - composition keywords (allOf, anyOf, oneOf, not, if/then/else) + +import { validate, is_valid } from "@stdlib/json_schema"; +import { parse } from "@stdlib/json"; + +// --- anyOf --- +let any_schema = { + anyOf: [ + { type: "string" }, + { type: "integer" } + ] +}; +assert(is_valid("hello", any_schema) == true, "anyOf: string matches"); +assert(is_valid(42, any_schema) == true, "anyOf: integer matches"); +assert(is_valid(true, any_schema) == false, "anyOf: bool matches neither"); +assert(is_valid(null, any_schema) == false, "anyOf: null matches neither"); + +// --- oneOf --- +let one_schema = { + oneOf: [ + { type: "string", minLength: 3 }, + { type: "string", maxLength: 5 } + ] +}; +// "hi" matches maxLength<=5 only +assert(is_valid("hi", one_schema) == true, "oneOf: matches only maxLength"); +// "toolongstring" matches minLength>=3 only +assert(is_valid("toolongstring", one_schema) == true, "oneOf: matches only minLength"); +// "abc" matches BOTH (minLength>=3 AND maxLength<=5), so oneOf fails +assert(is_valid("abc", one_schema) == false, "oneOf: matches both schemas"); + +// --- allOf --- +let all_schema = { + allOf: [ + { type: "object", required: ["name"] }, + { type: "object", required: ["age"] } + ] +}; +assert(is_valid({ name: "Alice", age: 30 }, all_schema) == true, "allOf: both required present"); +assert(is_valid({ name: "Alice" }, all_schema) == false, "allOf: missing age"); +assert(is_valid({ age: 30 }, all_schema) == false, "allOf: missing name"); + +// --- not --- +let not_schema = { + not: { type: "string" } +}; +assert(is_valid(42, not_schema) == true, "not: number is not string"); +assert(is_valid(null, not_schema) == true, "not: null is not string"); +assert(is_valid("hello", not_schema) == false, "not: string matches the 'not' schema"); + +// --- if/then/else --- +// Note: "if", "then", "else" are reserved keywords in Hemlock, +// so we build the schema from a JSON string. +let cond_schema = parse("{\"type\":\"object\",\"if\":{\"type\":\"object\",\"properties\":{\"kind\":{\"const\":\"email\"}},\"required\":[\"kind\"]},\"then\":{\"required\":[\"address\"]},\"else\":{\"required\":[\"phone\"]}}"); +assert(is_valid({ kind: "email", address: "a@b.com" }, cond_schema) == true, "if/then: email with address"); +assert(is_valid({ kind: "email" }, cond_schema) == false, "if/then: email without address"); +assert(is_valid({ kind: "sms", phone: "555" }, cond_schema) == true, "if/else: sms with phone"); +assert(is_valid({ kind: "sms" }, cond_schema) == false, "if/else: sms without phone"); + +// --- enum --- +let enum_schema = { enum: ["red", "green", "blue"] }; +assert(is_valid("red", enum_schema) == true, "enum: red in list"); +assert(is_valid("green", enum_schema) == true, "enum: green in list"); +assert(is_valid("yellow", enum_schema) == false, "enum: yellow not in list"); +assert(is_valid(42, enum_schema) == false, "enum: number not in list"); + +// --- const --- +let const_schema = { const: "fixed" }; +assert(is_valid("fixed", const_schema) == true, "const: exact match"); +assert(is_valid("other", const_schema) == false, "const: different string"); +assert(is_valid(42, const_schema) == false, "const: different type"); + +// --- nested composition --- +let complex = { + type: "object", + properties: { + value: { + anyOf: [ + { type: "string" }, + { type: "integer", minimum: 0 } + ] + } + }, + required: ["value"] +}; +assert(is_valid({ value: "hello" }, complex) == true, "nested anyOf: string"); +assert(is_valid({ value: 42 }, complex) == true, "nested anyOf: positive int"); +assert(is_valid({ value: -1 }, complex) == false, "nested anyOf: negative int"); +assert(is_valid({ value: true }, complex) == false, "nested anyOf: bool"); + +print("All composition tests passed!"); diff --git a/tests/stdlib_json_schema/number_test.hml b/tests/stdlib_json_schema/number_test.hml new file mode 100644 index 00000000..89fb1796 --- /dev/null +++ b/tests/stdlib_json_schema/number_test.hml @@ -0,0 +1,48 @@ +// Test @stdlib/json_schema - number constraints + +import { validate, is_valid } from "@stdlib/json_schema"; + +// --- minimum --- +let min_schema = { type: "number", minimum: 0 }; +assert(is_valid(0, min_schema) == true, "minimum: 0 is >= 0"); +assert(is_valid(10, min_schema) == true, "minimum: 10 is >= 0"); +assert(is_valid(-1, min_schema) == false, "minimum: -1 is not >= 0"); + +// --- maximum --- +let max_schema = { type: "number", maximum: 100 }; +assert(is_valid(100, max_schema) == true, "maximum: 100 is <= 100"); +assert(is_valid(50, max_schema) == true, "maximum: 50 is <= 100"); +assert(is_valid(101, max_schema) == false, "maximum: 101 is not <= 100"); + +// --- range --- +let range_schema = { type: "number", minimum: 1, maximum: 10 }; +assert(is_valid(1, range_schema) == true, "range: 1 in [1,10]"); +assert(is_valid(5, range_schema) == true, "range: 5 in [1,10]"); +assert(is_valid(10, range_schema) == true, "range: 10 in [1,10]"); +assert(is_valid(0, range_schema) == false, "range: 0 not in [1,10]"); +assert(is_valid(11, range_schema) == false, "range: 11 not in [1,10]"); + +// --- exclusiveMinimum --- +let emin_schema = { type: "number", exclusiveMinimum: 0 }; +assert(is_valid(1, emin_schema) == true, "exclusiveMinimum: 1 > 0"); +assert(is_valid(0, emin_schema) == false, "exclusiveMinimum: 0 is not > 0"); +assert(is_valid(-1, emin_schema) == false, "exclusiveMinimum: -1 is not > 0"); + +// --- exclusiveMaximum --- +let emax_schema = { type: "number", exclusiveMaximum: 100 }; +assert(is_valid(99, emax_schema) == true, "exclusiveMaximum: 99 < 100"); +assert(is_valid(100, emax_schema) == false, "exclusiveMaximum: 100 is not < 100"); + +// --- multipleOf --- +let mult_schema = { type: "integer", multipleOf: 3 }; +assert(is_valid(0, mult_schema) == true, "multipleOf: 0 is multiple of 3"); +assert(is_valid(3, mult_schema) == true, "multipleOf: 3 is multiple of 3"); +assert(is_valid(9, mult_schema) == true, "multipleOf: 9 is multiple of 3"); +assert(is_valid(4, mult_schema) == false, "multipleOf: 4 is not multiple of 3"); + +// --- error details for number --- +let result = validate(-5, min_schema); +assert(result.valid == false, "validate number out of range"); +assert(result.errors[0].message.contains("minimum"), "error mentions minimum"); + +print("All number constraint tests passed!"); diff --git a/tests/stdlib_json_schema/object_test.hml b/tests/stdlib_json_schema/object_test.hml new file mode 100644 index 00000000..e22fc833 --- /dev/null +++ b/tests/stdlib_json_schema/object_test.hml @@ -0,0 +1,81 @@ +// Test @stdlib/json_schema - object validation + +import { validate, is_valid } from "@stdlib/json_schema"; + +// --- required properties --- +let req_schema = { + type: "object", + required: ["name", "age"] +}; +assert(is_valid({ name: "Alice", age: 30 }, req_schema) == true, "required: both present"); +assert(is_valid({ name: "Alice", age: 30, extra: true }, req_schema) == true, "required: extra ok"); +assert(is_valid({ name: "Alice" }, req_schema) == false, "required: missing age"); +assert(is_valid({}, req_schema) == false, "required: empty object"); + +// --- properties validation --- +let props_schema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer", minimum: 0 }, + active: { type: "boolean" } + } +}; +assert(is_valid({ name: "Alice", age: 30, active: true }, props_schema) == true, "properties: all valid"); +assert(is_valid({ name: "Alice" }, props_schema) == true, "properties: partial ok (not required)"); +assert(is_valid({ name: 42 }, props_schema) == false, "properties: wrong type for name"); +assert(is_valid({ age: -1 }, props_schema) == false, "properties: age below minimum"); + +// --- additionalProperties: false --- +let strict_schema = { + type: "object", + properties: { + x: { type: "number" }, + y: { type: "number" } + }, + additionalProperties: false +}; +assert(is_valid({ x: 1, y: 2 }, strict_schema) == true, "additional false: exact match"); +assert(is_valid({ x: 1 }, strict_schema) == true, "additional false: subset ok"); +assert(is_valid({ x: 1, y: 2, z: 3 }, strict_schema) == false, "additional false: extra z"); + +// --- additionalProperties as schema --- +let typed_extra_schema = { + type: "object", + properties: { + name: { type: "string" } + }, + additionalProperties: { type: "integer" } +}; +assert(is_valid({ name: "test", count: 5 }, typed_extra_schema) == true, "additional schema: integer extra ok"); +assert(is_valid({ name: "test", count: "five" }, typed_extra_schema) == false, "additional schema: string extra fails"); + +// --- minProperties / maxProperties --- +let size_schema = { type: "object", minProperties: 1, maxProperties: 3 }; +assert(is_valid({ a: 1 }, size_schema) == true, "minProperties: 1 ok"); +assert(is_valid({ a: 1, b: 2, c: 3 }, size_schema) == true, "maxProperties: 3 ok"); +assert(is_valid({}, size_schema) == false, "minProperties: 0 too few"); +assert(is_valid({ a: 1, b: 2, c: 3, d: 4 }, size_schema) == false, "maxProperties: 4 too many"); + +// --- combined required + properties --- +let user_schema = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + email: { type: "string" }, + age: { type: "integer", minimum: 0, maximum: 150 } + }, + required: ["name", "email"] +}; +assert(is_valid({ name: "Alice", email: "alice@test.com", age: 30 }, user_schema) == true, "user: valid"); +assert(is_valid({ name: "Alice", email: "a@b.com" }, user_schema) == true, "user: without optional age"); +assert(is_valid({ name: "", email: "a@b.com" }, user_schema) == false, "user: empty name"); +assert(is_valid({ email: "a@b.com" }, user_schema) == false, "user: missing name"); + +// --- error paths for nested objects --- +let result = validate({ name: 42, age: -1 }, user_schema); +assert(result.valid == false, "nested errors detected"); +// Should have errors for: name type, age minimum, missing email +assert(result.errors.length >= 2, "multiple errors reported"); + +print("All object validation tests passed!"); diff --git a/tests/stdlib_json_schema/string_test.hml b/tests/stdlib_json_schema/string_test.hml new file mode 100644 index 00000000..8602662a --- /dev/null +++ b/tests/stdlib_json_schema/string_test.hml @@ -0,0 +1,31 @@ +// Test @stdlib/json_schema - string constraints + +import { validate, is_valid } from "@stdlib/json_schema"; + +// --- minLength --- +let min_schema = { type: "string", minLength: 3 }; +assert(is_valid("abc", min_schema) == true, "minLength: 'abc' has length 3"); +assert(is_valid("abcd", min_schema) == true, "minLength: 'abcd' has length 4"); +assert(is_valid("ab", min_schema) == false, "minLength: 'ab' has length 2"); +assert(is_valid("", min_schema) == false, "minLength: empty string too short"); + +// --- maxLength --- +let max_schema = { type: "string", maxLength: 5 }; +assert(is_valid("hello", max_schema) == true, "maxLength: 'hello' has length 5"); +assert(is_valid("hi", max_schema) == true, "maxLength: 'hi' has length 2"); +assert(is_valid("helloo", max_schema) == false, "maxLength: 'helloo' has length 6"); + +// --- combined minLength and maxLength --- +let len_schema = { type: "string", minLength: 2, maxLength: 5 }; +assert(is_valid("ab", len_schema) == true, "length range: 'ab' ok"); +assert(is_valid("abcde", len_schema) == true, "length range: 'abcde' ok"); +assert(is_valid("a", len_schema) == false, "length range: 'a' too short"); +assert(is_valid("abcdef", len_schema) == false, "length range: 'abcdef' too long"); + +// --- error details --- +let result = validate("a", min_schema); +assert(result.valid == false, "validate short string"); +assert(result.errors.length == 1, "one error for short string"); +assert(result.errors[0].message.contains("minLength"), "error mentions minLength"); + +print("All string constraint tests passed!"); diff --git a/tests/stdlib_json_schema/type_test.hml b/tests/stdlib_json_schema/type_test.hml new file mode 100644 index 00000000..21fe6947 --- /dev/null +++ b/tests/stdlib_json_schema/type_test.hml @@ -0,0 +1,81 @@ +// Test @stdlib/json_schema - type validation + +import { validate, is_valid } from "@stdlib/json_schema"; + +// --- string type --- +let str_schema = { type: "string" }; +assert(is_valid("hello", str_schema) == true, "string validates string"); +assert(is_valid("", str_schema) == true, "string validates empty string"); +assert(is_valid(42, str_schema) == false, "string rejects number"); +assert(is_valid(true, str_schema) == false, "string rejects bool"); +assert(is_valid(null, str_schema) == false, "string rejects null"); +assert(is_valid([], str_schema) == false, "string rejects array"); +assert(is_valid({}, str_schema) == false, "string rejects object"); + +// --- number type --- +let num_schema = { type: "number" }; +assert(is_valid(42, num_schema) == true, "number validates integer"); +assert(is_valid(3.14, num_schema) == true, "number validates float"); +assert(is_valid(0, num_schema) == true, "number validates zero"); +assert(is_valid("42", num_schema) == false, "number rejects string"); +assert(is_valid(true, num_schema) == false, "number rejects bool"); + +// --- integer type --- +let int_schema = { type: "integer" }; +assert(is_valid(42, int_schema) == true, "integer validates i32"); +assert(is_valid(0, int_schema) == true, "integer validates zero"); +assert(is_valid(3.14, int_schema) == false, "integer rejects float"); +assert(is_valid("42", int_schema) == false, "integer rejects string"); + +// --- boolean type --- +let bool_schema = { type: "boolean" }; +assert(is_valid(true, bool_schema) == true, "boolean validates true"); +assert(is_valid(false, bool_schema) == true, "boolean validates false"); +assert(is_valid(1, bool_schema) == false, "boolean rejects number"); +assert(is_valid("true", bool_schema) == false, "boolean rejects string"); + +// --- null type --- +let null_schema = { type: "null" }; +assert(is_valid(null, null_schema) == true, "null validates null"); +assert(is_valid(0, null_schema) == false, "null rejects zero"); +assert(is_valid("", null_schema) == false, "null rejects empty string"); +assert(is_valid(false, null_schema) == false, "null rejects false"); + +// --- array type --- +let arr_schema = { type: "array" }; +assert(is_valid([], arr_schema) == true, "array validates empty array"); +assert(is_valid([1, 2, 3], arr_schema) == true, "array validates non-empty"); +assert(is_valid({}, arr_schema) == false, "array rejects object"); +assert(is_valid("[]", arr_schema) == false, "array rejects string"); + +// --- object type --- +let obj_schema = { type: "object" }; +assert(is_valid({}, obj_schema) == true, "object validates empty object"); +assert(is_valid({ x: 10 }, obj_schema) == true, "object validates non-empty"); +assert(is_valid([], obj_schema) == false, "object rejects array"); +assert(is_valid("object", obj_schema) == false, "object rejects string"); + +// --- union types --- +let nullable_str = { type: ["string", "null"] }; +assert(is_valid("hello", nullable_str) == true, "union accepts string"); +assert(is_valid(null, nullable_str) == true, "union accepts null"); +assert(is_valid(42, nullable_str) == false, "union rejects number"); + +let str_or_num = { type: ["string", "number"] }; +assert(is_valid("hello", str_or_num) == true, "string|number accepts string"); +assert(is_valid(42, str_or_num) == true, "string|number accepts number"); +assert(is_valid(true, str_or_num) == false, "string|number rejects bool"); + +// --- validate() returns error details --- +let result = validate(42, str_schema); +assert(result.valid == false, "validate returns valid=false"); +assert(result.errors.length == 1, "validate returns 1 error"); +assert(result.errors[0].message.contains("expected type"), "error message mentions type"); + +// --- boolean schemas --- +assert(is_valid("anything", true) == true, "boolean true schema accepts anything"); +assert(is_valid(42, true) == true, "boolean true schema accepts number"); +assert(is_valid("anything", false) == false, "boolean false schema rejects everything"); +assert(is_valid(null, false) == false, "boolean false schema rejects null"); + +print("All type validation tests passed!"); diff --git a/tests/stdlib_json_schema/validate_json_test.hml b/tests/stdlib_json_schema/validate_json_test.hml new file mode 100644 index 00000000..2d235d3a --- /dev/null +++ b/tests/stdlib_json_schema/validate_json_test.hml @@ -0,0 +1,76 @@ +// Test @stdlib/json_schema - validate_json (string input) and format_errors + +import { validate_json, validate, format_errors, is_valid } from "@stdlib/json_schema"; + +// --- validate_json with valid JSON string --- +let schema = { + type: "object", + properties: { + name: { type: "string" }, + value: { type: "integer" } + }, + required: ["name"] +}; + +let result1 = validate_json("{\"name\":\"test\",\"value\":42}", schema); +assert(result1.valid == true, "validate_json: valid input"); +assert(result1.errors.length == 0, "validate_json: no errors"); + +// --- validate_json with invalid JSON --- +let result2 = validate_json("{bad json", schema); +assert(result2.valid == false, "validate_json: invalid JSON"); +assert(result2.errors.length == 1, "validate_json: one parse error"); +assert(result2.errors[0].message.contains("invalid JSON"), "validate_json: error mentions invalid JSON"); + +// --- validate_json with valid JSON but schema mismatch --- +let result3 = validate_json("{\"value\":42}", schema); +assert(result3.valid == false, "validate_json: missing required"); +assert(result3.errors.length == 1, "validate_json: one schema error"); + +// --- format_errors --- +let result4 = validate({ name: 42, extra: true }, { + type: "object", + properties: { name: { type: "string" } }, + additionalProperties: false +}); +assert(result4.valid == false, "format_errors: has errors"); +let formatted = format_errors(result4.errors); +assert(typeof(formatted) == "string", "format_errors: returns string"); +assert(formatted.length > 0, "format_errors: non-empty"); + +// --- format_errors with no errors --- +let empty = format_errors([]); +assert(empty == "no errors", "format_errors: empty returns 'no errors'"); + +// --- error path tracing --- +let deep_schema = { + type: "object", + properties: { + users: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" } + }, + required: ["name"] + } + } + } +}; + +let deep_data = { + users: [ + { name: "Alice" }, + { name: 42 }, + {} + ] +}; + +let result5 = validate(deep_data, deep_schema); +assert(result5.valid == false, "deep errors: invalid"); +// Should have error for users[1].name type and users[2] missing name +let err_text = format_errors(result5.errors); +assert(err_text.contains("name"), "deep errors: mentions name"); + +print("All validate_json and format_errors tests passed!");