forked from weaselab/weaseljson
Add JSON Schema -> C++ streaming parser generator
Generates a C++ type and a streaming builder parser from a JSON Schema, built on weaseljson. The builder copies incoming bytes directly into their final destinations in the result struct (no intermediate DOM) and hands back ownership via take() once parsing completes. Supports objects/structs, required vs optional (std::optional) fields, nullable types, string enums, arrays, $ref including recursion (broken with unique_ptr), and additionalProperties (strict reject or skip). Unsupported schema constructs are rejected at generation time; schema violations are rejected at parse time.
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# weaseljson schemagen
|
||||||
|
|
||||||
|
Generates a C++ type and a streaming **builder** parser from a JSON Schema,
|
||||||
|
on top of weaseljson.
|
||||||
|
|
||||||
|
The builder copies incoming bytes straight into their final destinations in the
|
||||||
|
result struct as they arrive (no intermediate DOM). When parsing completes you
|
||||||
|
`take()` ownership of the result.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 weaseljson_schemagen.py schema.json -o parsed.h --namespace myschema
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "parsed.h"
|
||||||
|
using namespace myschema;
|
||||||
|
|
||||||
|
RootBuilder b;
|
||||||
|
WeaselJsonStatus s = b.feed(buf, len); // call repeatedly with chunks; buf may
|
||||||
|
// be modified in place (unescaping)
|
||||||
|
if (s == WeaselJson_AGAIN) s = b.finish();
|
||||||
|
if (s == WeaselJson_OK) {
|
||||||
|
Root value = b.take(); // ownership of the parsed result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`feed`/`finish` return the usual `WeaselJsonStatus`; `WeaselJson_OK` means the
|
||||||
|
document is both valid JSON and schema-valid, and `WeaselJson_REJECT` covers
|
||||||
|
both malformed JSON and schema violations. The builder holds interior pointers
|
||||||
|
into the result, so it is non-movable.
|
||||||
|
|
||||||
|
## Schema -> C++ mapping
|
||||||
|
|
||||||
|
| JSON Schema | C++ |
|
||||||
|
|-----------------------------------------------|----------------------------------------|
|
||||||
|
| `object` with `properties` | `struct` |
|
||||||
|
| required property | value member |
|
||||||
|
| non-required property | `std::optional<T>` |
|
||||||
|
| `["T", "null"]` (nullable) | `std::optional<T>` (accepts `null`) |
|
||||||
|
| `string` / `integer` / `number` / `boolean` | `std::string` / `int64_t` / `double` / `bool` |
|
||||||
|
| `enum` of strings | `enum class : int` |
|
||||||
|
| `array` with `items` | `std::vector<T>` |
|
||||||
|
| `$ref` to `$defs`/`definitions` | the referenced named struct |
|
||||||
|
| recursive `$ref` | `std::unique_ptr<T>` (cycle broken) |
|
||||||
|
| `additionalProperties: false` | unknown keys rejected |
|
||||||
|
| `additionalProperties` absent / `true` | unknown keys' values skipped |
|
||||||
|
|
||||||
|
## Schema violations (rejected at parse time)
|
||||||
|
|
||||||
|
- missing required property (top-level or nested)
|
||||||
|
- wrong JSON type for a property / array element
|
||||||
|
- `null` for a non-nullable slot
|
||||||
|
- value not in a string `enum`
|
||||||
|
- a number not representable in the target type (e.g. `1.5` for an `integer`)
|
||||||
|
- duplicate object keys
|
||||||
|
- unknown key under `additionalProperties: false`
|
||||||
|
|
||||||
|
## Not supported (rejected at generation time, no fallback)
|
||||||
|
|
||||||
|
`oneOf` / `anyOf` / `allOf` / `not` / `if`-`then`-`else`,
|
||||||
|
`patternProperties`, `additionalProperties` with a schema (typed map),
|
||||||
|
`prefixItems` (tuples), `const`, `dependentSchemas`/`dependentRequired`,
|
||||||
|
union `type` lists other than `["T", "null"]`, non-string enums, and remote
|
||||||
|
(`$ref` to other documents).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Strings (the bulk payload) are appended directly into their destination
|
||||||
|
`std::string`, even when split across `feed` calls. Numbers are accumulated in
|
||||||
|
a small scratch buffer and converted on completion, since an `int64_t`/`double`
|
||||||
|
cannot hold partial digits.
|
||||||
|
- `test_gen.cpp` generates from `example.schema.json` and exercises the parser
|
||||||
|
byte-by-byte (covering chunked strings/numbers), plus the rejection cases.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"age"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"enum": [
|
||||||
|
"admin",
|
||||||
|
"user",
|
||||||
|
"guest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hobbies": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"city"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"city": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"zip": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"friends": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"$ref": "#/$defs/Node"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"Node": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"$ref": "#/$defs/Node"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/Node"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Test harness for the generated example.schema.json parser.
|
||||||
|
// Feeds input one byte at a time to exercise chunked string/number paths.
|
||||||
|
#include <cassert>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "gen.h"
|
||||||
|
|
||||||
|
using namespace weasel_schema;
|
||||||
|
|
||||||
|
// Feed `in` one byte at a time. Returns final status.
|
||||||
|
static WeaselJsonStatus parseStrided(RootBuilder &b, std::string in) {
|
||||||
|
for (size_t i = 0; i < in.size(); ++i) {
|
||||||
|
char c = in[i];
|
||||||
|
WeaselJsonStatus s = b.feed(&c, 1);
|
||||||
|
if (s != WeaselJson_AGAIN)
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return b.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int failures = 0;
|
||||||
|
#define CHECK(cond) \
|
||||||
|
do { \
|
||||||
|
if (!(cond)) { \
|
||||||
|
printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
|
||||||
|
++failures; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static void expectReject(const std::string &json, const char *what) {
|
||||||
|
RootBuilder b;
|
||||||
|
WeaselJsonStatus s = parseStrided(b, json);
|
||||||
|
if (s == WeaselJson_REJECT) {
|
||||||
|
printf("ok reject: %s\n", what);
|
||||||
|
} else {
|
||||||
|
printf("FAIL expected reject (%s) got status %d for: %s\n", what, s,
|
||||||
|
json.c_str());
|
||||||
|
++failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// ---- happy path, full feature coverage, byte-strided ----
|
||||||
|
{
|
||||||
|
std::string json = R"({
|
||||||
|
"name": "Ada É",
|
||||||
|
"age": 36,
|
||||||
|
"height": 1.75,
|
||||||
|
"active": true,
|
||||||
|
"nickname": null,
|
||||||
|
"role": "admin",
|
||||||
|
"hobbies": ["math", "lace"],
|
||||||
|
"address": { "city": "London", "zip": 12345 },
|
||||||
|
"friends": [
|
||||||
|
{ "name": "Bob" },
|
||||||
|
{ "name": "Cay", "age": 40 }
|
||||||
|
],
|
||||||
|
"tree": {
|
||||||
|
"value": 1,
|
||||||
|
"children": [ { "value": 2 }, { "value": 3 } ],
|
||||||
|
"next": { "value": 99 }
|
||||||
|
},
|
||||||
|
"extra_unknown": { "ignored": [1, 2, {"x": "y"}] }
|
||||||
|
})";
|
||||||
|
// Root is strict (additionalProperties:false), so "extra_unknown" must
|
||||||
|
// make it reject. Verify that, then test a clean version.
|
||||||
|
expectReject(json, "unknown key in strict root");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
std::string json = R"({
|
||||||
|
"name": "Ada É",
|
||||||
|
"age": 36,
|
||||||
|
"height": 1.75,
|
||||||
|
"active": true,
|
||||||
|
"nickname": null,
|
||||||
|
"role": "admin",
|
||||||
|
"hobbies": ["math", "lace"],
|
||||||
|
"address": { "city": "London", "zip": 12345 },
|
||||||
|
"friends": [
|
||||||
|
{ "name": "Bob" },
|
||||||
|
{ "name": "Cay", "age": 40 }
|
||||||
|
],
|
||||||
|
"tree": {
|
||||||
|
"value": 1,
|
||||||
|
"children": [ { "value": 2 }, { "value": 3 } ],
|
||||||
|
"next": { "value": 99 }
|
||||||
|
}
|
||||||
|
})";
|
||||||
|
RootBuilder b;
|
||||||
|
WeaselJsonStatus s = parseStrided(b, json);
|
||||||
|
CHECK(s == WeaselJson_OK);
|
||||||
|
if (s == WeaselJson_OK) {
|
||||||
|
Root r = b.take();
|
||||||
|
CHECK(r.name == "Ada \xC3\x89"); // É -> UTF-8
|
||||||
|
CHECK(r.age == 36);
|
||||||
|
CHECK(r.height > 1.74 && r.height < 1.76);
|
||||||
|
CHECK(r.active.has_value() && *r.active == true);
|
||||||
|
CHECK(!r.nickname.has_value()); // explicit null
|
||||||
|
CHECK(r.role.has_value() && *r.role == Role::admin);
|
||||||
|
CHECK(r.hobbies.has_value() && r.hobbies->size() == 2);
|
||||||
|
CHECK(r.hobbies && (*r.hobbies)[0] == "math" &&
|
||||||
|
(*r.hobbies)[1] == "lace");
|
||||||
|
CHECK(r.address.has_value() && r.address->city == "London");
|
||||||
|
CHECK(r.address && r.address->zip.has_value() &&
|
||||||
|
*r.address->zip == 12345);
|
||||||
|
CHECK(r.friends.has_value() && r.friends->size() == 2);
|
||||||
|
CHECK(r.friends && (*r.friends)[0].name == "Bob");
|
||||||
|
CHECK(r.friends && !(*r.friends)[0].age.has_value());
|
||||||
|
CHECK(r.friends && (*r.friends)[1].age.has_value() &&
|
||||||
|
*(*r.friends)[1].age == 40);
|
||||||
|
CHECK(r.tree.has_value() && r.tree->value == 1);
|
||||||
|
CHECK(r.tree && r.tree->children.has_value() &&
|
||||||
|
r.tree->children->size() == 2);
|
||||||
|
CHECK(r.tree && r.tree->children && (*r.tree->children)[0].value == 2);
|
||||||
|
CHECK(r.tree && r.tree->children && (*r.tree->children)[1].value == 3);
|
||||||
|
CHECK(r.tree && r.tree->next && r.tree->next->value == 99);
|
||||||
|
printf("ok happy path (byte-strided)\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- whole-buffer feed (not strided) ----
|
||||||
|
{
|
||||||
|
std::string json = R"({"name":"x","age":7})";
|
||||||
|
RootBuilder b;
|
||||||
|
WeaselJsonStatus s = b.feed(json.data(), (int)json.size());
|
||||||
|
if (s == WeaselJson_AGAIN)
|
||||||
|
s = b.finish();
|
||||||
|
CHECK(s == WeaselJson_OK);
|
||||||
|
Root r = b.take();
|
||||||
|
CHECK(r.name == "x" && r.age == 7);
|
||||||
|
CHECK(!r.role.has_value() && !r.hobbies.has_value());
|
||||||
|
printf("ok minimal object\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- schema-violation rejections ----
|
||||||
|
expectReject(R"({"name":"x"})", "missing required field 'age'");
|
||||||
|
expectReject(R"({"name":"x","age":"notnum"})", "wrong type (string for int)");
|
||||||
|
expectReject(R"({"name":"x","age":1,"role":"boss"})", "bad enum value");
|
||||||
|
expectReject(R"({"name":"x","age":1,"name":"y"})", "duplicate key");
|
||||||
|
expectReject(R"({"name":"x","age":1,"active":null})",
|
||||||
|
"null for non-nullable");
|
||||||
|
expectReject(R"({"name":"x","age":1,"age":2})", "duplicate required key");
|
||||||
|
expectReject(R"({"name":"x","age":1.5})", "fractional for integer");
|
||||||
|
expectReject(R"({"name":"x","age":1,"address":{"zip":5}})",
|
||||||
|
"missing required nested 'city'");
|
||||||
|
expectReject(R"([1,2,3])", "array where object expected (root)");
|
||||||
|
expectReject(R"({"name":"x","age":1,)", "truncated / invalid json");
|
||||||
|
|
||||||
|
if (failures == 0) {
|
||||||
|
printf("\nALL TESTS PASSED\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
printf("\n%d FAILURE(S)\n", failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user