├── .gitignore ├── LICENSE ├── README.md ├── build.zig └── src ├── json_dump.zig └── main.zig /.gitignore: -------------------------------------------------------------------------------- 1 | zig-cache/ 2 | .git/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Haze Booth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zig-json-decode 2 | 3 | To use, simply clone and link in your zig project 4 | 5 | # Example 6 | ```zig 7 | // ./build.zig ===== 8 | exe.addPackagePath("zig-json-decode", "zig-json-decode/src/main.zig"); 9 | 10 | // ./src/main.zig ===== 11 | const Decodable = @import("zig-json-decode").Decodable; 12 | const Skeleton = struct { 13 | key: []const u8, 14 | 15 | // json key mapping 16 | const json_key: []const u8 = "oddly_named_key"; 17 | }; 18 | 19 | const FleshedType = Decodable(Skeleton); 20 | //... 21 | const json = 22 | \\{"oddly_named_key": "test"} 23 | ; 24 | const foo = try FleshedType.fromJson(.{}, allocator, (try parser.parse(json)).root.Object); 25 | ``` 26 | 27 | # Features 28 | - [x] Map json keys to struct fields 29 | - [x] Dump objects as JSON 30 | - [x] Create custom decode functions specifically tailored to skeleton structs 31 | 32 | # TODO 33 | - [x] Alternative key names 34 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const Builder = @import("std").build.Builder; 2 | 3 | pub fn build(b: *Builder) void { 4 | const mode = b.standardReleaseOptions(); 5 | const lib = b.addStaticLibrary("zig-json-decode", "src/main.zig"); 6 | lib.setBuildMode(mode); 7 | lib.install(); 8 | 9 | var main_tests = b.addTest("src/main.zig"); 10 | main_tests.setBuildMode(mode); 11 | 12 | var dump_tests = b.addTest("src/json_dump.zig"); 13 | dump_tests.setBuildMode(mode); 14 | 15 | const test_step = b.step("test", "Run library tests"); 16 | test_step.dependOn(&main_tests.step); 17 | test_step.dependOn(&dump_tests.step); 18 | } 19 | -------------------------------------------------------------------------------- /src/json_dump.zig: -------------------------------------------------------------------------------- 1 | // The MIT License (Expat) 2 | 3 | // Copyright (c) 2015 Andrew Kelley 4 | 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | 12 | // The above copyright notice and this permission notice shall be included in 13 | // all copies or substantial portions of the Software. 14 | 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | // THE SOFTWARE. 22 | 23 | // FOLLOWING CODE THANKS TO @daurnimator (https://github.com/ziglang/zig/pull/3155) 24 | 25 | const std = @import("std"); 26 | const mem = std.mem; 27 | 28 | pub const JsonDumpOptions = struct { 29 | // TODO: indentation options? 30 | // TODO: make escaping '/' in strings optional? 31 | // TODO: allow picking if []u8 is string or array? 32 | }; 33 | 34 | pub fn dump( 35 | value: var, 36 | options: JsonDumpOptions, 37 | context: var, 38 | comptime Errors: type, 39 | output: fn (@TypeOf(context), []const u8) Errors!void, 40 | ) Errors!void { 41 | const T = @TypeOf(value); 42 | switch (@typeInfo(T)) { 43 | .Float, .ComptimeFloat => { 44 | return std.fmt.formatFloatScientific(value, std.fmt.FormatOptions{}, context, Errors, output); 45 | }, 46 | .Int, .ComptimeInt => { 47 | return std.fmt.formatIntValue(value, "", std.fmt.FormatOptions{}, context, Errors, output); 48 | }, 49 | .Bool => { 50 | return output(context, if (value) "true" else "false"); 51 | }, 52 | .Optional => { 53 | if (value) |payload| { 54 | return try dump(payload, options, context, Errors, output); 55 | } else { 56 | return output(context, "null"); 57 | } 58 | }, 59 | .Enum => { 60 | if (comptime std.meta.trait.hasFn("jsonDump")(T)) { 61 | return value.jsonDump(options, context, Errors, output); 62 | } 63 | 64 | @compileError("Unable to dump enum '" ++ @typeName(T) ++ "'"); 65 | }, 66 | .Union => { 67 | if (comptime std.meta.trait.hasFn("jsonDump")(T)) { 68 | return value.jsonDump(options, context, Errors, output); 69 | } 70 | 71 | const info = @typeInfo(T).Union; 72 | if (info.tag_type) |UnionTagType| { 73 | inline for (info.fields) |u_field| { 74 | if (@enumToInt(@as(UnionTagType, value)) == u_field.enum_field.?.value) { 75 | return try dump(@field(value, u_field.name), options, context, Errors, output); 76 | } 77 | } 78 | } else { 79 | @compileError("Unable to dump untagged union '" ++ @typeName(T) ++ "'"); 80 | } 81 | }, 82 | .Struct => |S| { 83 | if (comptime std.meta.trait.hasFn("jsonDump")(T)) { 84 | return value.jsonDump(options, context, Errors, output); 85 | } 86 | 87 | try output(context, "{"); 88 | comptime var field_output = false; 89 | inline for (S.fields) |Field, field_i| { 90 | // don't include void fields 91 | if (Field.field_type == void) continue; 92 | 93 | if (!field_output) { 94 | field_output = true; 95 | } else { 96 | try output(context, ","); 97 | } 98 | 99 | try dump(Field.name, options, context, Errors, output); 100 | try output(context, ":"); 101 | try dump(@field(value, Field.name), options, context, Errors, output); 102 | } 103 | try output(context, "}"); 104 | return; 105 | }, 106 | .Pointer => |ptr_info| switch (ptr_info.size) { 107 | .One => { 108 | // TODO: avoid loops? 109 | return try dump(value.*, options, context, Errors, output); 110 | }, 111 | // TODO: .Many when there is a sentinel (waiting for https://github.com/ziglang/zig/pull/3972) 112 | .Slice => { 113 | if (ptr_info.child == u8 and std.unicode.utf8ValidateSlice(value)) { 114 | try output(context, "\""); 115 | var i: usize = 0; 116 | while (i < value.len) : (i += 1) { 117 | switch (value[i]) { 118 | // normal ascii characters 119 | 0x20...0x21, 0x23...0x2E, 0x30...0x5B, 0x5D...0x7F => try output(context, value[i .. i + 1]), 120 | // control characters with short escapes 121 | '\\' => try output(context, "\\\\"), 122 | '\"' => try output(context, "\\\""), 123 | '/' => try output(context, "\\/"), 124 | 0x8 => try output(context, "\\b"), 125 | 0xC => try output(context, "\\f"), 126 | '\n' => try output(context, "\\n"), 127 | '\r' => try output(context, "\\r"), 128 | '\t' => try output(context, "\\t"), 129 | else => { 130 | const ulen = std.unicode.utf8ByteSequenceLength(value[i]) catch unreachable; 131 | const codepoint = std.unicode.utf8Decode(value[i .. i + ulen]) catch unreachable; 132 | if (codepoint <= 0xFFFF) { 133 | // If the character is in the Basic Multilingual Plane (U+0000 through U+FFFF), 134 | // then it may be represented as a six-character sequence: a reverse solidus, followed 135 | // by the lowercase letter u, followed by four hexadecimal digits that encode the character's code point. 136 | try output(context, "\\u"); 137 | try std.fmt.formatIntValue(codepoint, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, context, Errors, output); 138 | } else { 139 | // To escape an extended character that is not in the Basic Multilingual Plane, 140 | // the character is represented as a 12-character sequence, encoding the UTF-16 surrogate pair. 141 | const high = @intCast(u16, (codepoint - 0x10000) >> 10) + 0xD800; 142 | const low = @intCast(u16, codepoint & 0x3FF) + 0xDC00; 143 | try output(context, "\\u"); 144 | try std.fmt.formatIntValue(high, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, context, Errors, output); 145 | try output(context, "\\u"); 146 | try std.fmt.formatIntValue(low, "x", std.fmt.FormatOptions{ .width = 4, .fill = '0' }, context, Errors, output); 147 | } 148 | i += ulen - 1; 149 | }, 150 | } 151 | } 152 | try output(context, "\""); 153 | return; 154 | } 155 | 156 | try output(context, "["); 157 | for (value) |x, i| { 158 | if (i != 0) { 159 | try output(context, ","); 160 | } 161 | try dump(x, options, context, Errors, output); 162 | } 163 | try output(context, "]"); 164 | return; 165 | }, 166 | else => @compileError("Unable to dump type '" ++ @typeName(T) ++ "'"), 167 | }, 168 | .Array => |info| { 169 | return try dump(value[0..], options, context, Errors, output); 170 | }, 171 | else => @compileError("Unable to dump type '" ++ @typeName(T) ++ "'"), 172 | } 173 | unreachable; 174 | } 175 | 176 | fn testDump(expected: []const u8, value: var) !void { 177 | const TestDumpContext = struct { 178 | expected_remaining: []const u8, 179 | fn testDumpWrite(context: *@This(), bytes: []const u8) !void { 180 | if (context.expected_remaining.len < bytes.len) { 181 | std.debug.warn( 182 | \\====== expected this output: ========= 183 | \\{} 184 | \\======== instead found this: ========= 185 | \\{} 186 | \\====================================== 187 | , .{ 188 | context.expected_remaining, 189 | bytes, 190 | }); 191 | return error.TooMuchData; 192 | } 193 | if (!mem.eql(u8, context.expected_remaining[0..bytes.len], bytes)) { 194 | std.debug.warn( 195 | \\====== expected this output: ========= 196 | \\{} 197 | \\======== instead found this: ========= 198 | \\{} 199 | \\====================================== 200 | , .{ 201 | context.expected_remaining[0..bytes.len], 202 | bytes, 203 | }); 204 | return error.DifferentData; 205 | } 206 | context.expected_remaining = context.expected_remaining[bytes.len..]; 207 | } 208 | }; 209 | var buf: [100]u8 = undefined; 210 | var context = TestDumpContext{ .expected_remaining = expected }; 211 | try dump(value, JsonDumpOptions{}, &context, error{ 212 | TooMuchData, 213 | DifferentData, 214 | }, TestDumpContext.testDumpWrite); 215 | if (context.expected_remaining.len > 0) return error.NotEnoughData; 216 | } 217 | 218 | test "dump basic types" { 219 | try testDump("false", false); 220 | try testDump("true", true); 221 | try testDump("null", @as(?u8, null)); 222 | try testDump("null", @as(?*u32, null)); 223 | try testDump("42", 42); 224 | try testDump("4.2e+01", 42.0); 225 | try testDump("42", @as(u8, 42)); 226 | try testDump("42", @as(u128, 42)); 227 | try testDump("4.2e+01", @as(f32, 42)); 228 | try testDump("4.2e+01", @as(f64, 42)); 229 | } 230 | 231 | test "dump string" { 232 | try testDump("\"hello\"", "hello"); 233 | try testDump("\"with\\nescapes\\r\"", "with\nescapes\r"); 234 | try testDump("\"with unicode\\u0001\"", "with unicode\u{1}"); 235 | try testDump("\"with unicode\\u0080\"", "with unicode\u{80}"); 236 | try testDump("\"with unicode\\u00ff\"", "with unicode\u{FF}"); 237 | try testDump("\"with unicode\\u0100\"", "with unicode\u{100}"); 238 | try testDump("\"with unicode\\u0800\"", "with unicode\u{800}"); 239 | try testDump("\"with unicode\\u8000\"", "with unicode\u{8000}"); 240 | try testDump("\"with unicode\\ud799\"", "with unicode\u{D799}"); 241 | try testDump("\"with unicode\\ud800\\udc00\"", "with unicode\u{10000}"); 242 | try testDump("\"with unicode\\udbff\\udfff\"", "with unicode\u{10FFFF}"); 243 | } 244 | 245 | test "dump tagged unions" { 246 | try testDump("42", union(enum) { 247 | Foo: u32, 248 | Bar: bool, 249 | }{ .Foo = 42 }); 250 | } 251 | 252 | test "dump struct" { 253 | try testDump("{\"foo\":42}", struct { 254 | foo: u32, 255 | }{ .foo = 42 }); 256 | } 257 | 258 | test "dump struct with void field" { 259 | try testDump("{\"foo\":42}", struct { 260 | foo: u32, 261 | bar: void = {}, 262 | }{ .foo = 42 }); 263 | } 264 | 265 | test "dump array of structs" { 266 | const MyStruct = struct { 267 | foo: u32, 268 | }; 269 | try testDump("[{\"foo\":42},{\"foo\":100},{\"foo\":1000}]", [_]MyStruct{ 270 | MyStruct{ .foo = 42 }, 271 | MyStruct{ .foo = 100 }, 272 | MyStruct{ .foo = 1000 }, 273 | }); 274 | } 275 | 276 | test "dump struct with custom dumper" { 277 | try testDump("[\"something special\",42]", struct { 278 | foo: u32, 279 | const Self = @This(); 280 | pub fn jsonDump( 281 | value: Self, 282 | options: JsonDumpOptions, 283 | context: var, 284 | comptime Errors: type, 285 | output: fn (@TypeOf(context), []const u8) Errors!void, 286 | ) !void { 287 | try output(context, "[\"something special\","); 288 | try dump(42, options, context, Errors, output); 289 | try output(context, "]"); 290 | } 291 | }{ .foo = 42 }); 292 | } 293 | -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const testing = std.testing; 3 | const mem = std.mem; 4 | const json_key_prefix = "json_"; 5 | 6 | pub const dump = @import("json_dump.zig").dump; 7 | 8 | pub const DecodeOptions = struct { 9 | ignoreMissing: bool = false, 10 | }; 11 | 12 | fn fieldCount(comptime T: type) usize { 13 | const typeInfo = @typeInfo(T); 14 | if (typeInfo != .Struct) @compileError("Attempted to call fieldCount() on a non struct"); 15 | comptime var count = 0; 16 | inline for (typeInfo.Struct.fields) |field| { 17 | if (!mem.startsWith(u8, field.name, json_key_prefix)) { 18 | count += 1; 19 | } 20 | } 21 | return count; 22 | } 23 | 24 | pub fn Decodable(comptime T: type) type { 25 | const info = @typeInfo(T); 26 | if (info != .Struct) @compileError("Attempted to call Decodable() on a non struct (" ++ @typeName(T) ++ ")"); 27 | return struct { 28 | const Self = @This(); 29 | 30 | const Error = mem.Allocator.Error || error{MissingField}; 31 | 32 | pub fn fromJson(options: DecodeOptions, allocator: *mem.Allocator, node: var) Error!T { 33 | var item: *T = try allocator.create(T); 34 | if (info != .Struct) unreachable; 35 | // hot path for missing 36 | if (!options.ignoreMissing and fieldCount(T) != node.count()) return error.MissingField; 37 | inline for (info.Struct.fields) |field| { 38 | const maybeJsonMapping = json_key_prefix ++ field.name; 39 | const fieldName = field.name; 40 | const accessorKey = if (@hasDecl(T, maybeJsonMapping)) @field(T, maybeJsonMapping) else field.name; 41 | const fieldTypeInfo = @typeInfo(field.field_type); 42 | if (node.get(accessorKey)) |obj| { 43 | if (fieldTypeInfo == .Struct) { // complex json type 44 | const generatedType = Decodable(field.field_type); 45 | @field(item, fieldName) = try generatedType.fromJson(options, allocator, obj.value.Object); 46 | } else if (fieldTypeInfo == .Pointer and field.field_type != []const u8) { // strings are handled 47 | const arrayType = fieldTypeInfo.Pointer.child; 48 | const values = obj.value.Array; 49 | var dest = try allocator.alloc(arrayType, values.toSliceConst().len); 50 | if (comptime isNativeJSONType(arrayType)) { 51 | for (values.toSliceConst()) |value, index| { 52 | dest[index] = switch (arrayType) { 53 | i64 => value.Integer, 54 | f64 => value.Float, 55 | bool => value.Bool, 56 | []const u8 => value.String, 57 | else => unreachable, 58 | }; 59 | } 60 | } else { 61 | const generatedArrayType = Decodable(arrayType); 62 | for (values.toSliceConst()) |value, index| { 63 | dest[index] = try generatedArrayType.fromJson(options, allocator, value.Object); 64 | } 65 | } 66 | @field(item, fieldName) = dest; 67 | } else if (fieldTypeInfo == .Optional) { 68 | if (obj.value == .Null) { 69 | @field(item, fieldName) = null; 70 | } else { 71 | const childType = fieldTypeInfo.Optional.child; 72 | assignRawType(item, fieldName, childType, obj); 73 | } 74 | } else { 75 | assignRawType(item, fieldName, field.field_type, obj); 76 | } 77 | } else if (!options.ignoreMissing) return error.MissingField; 78 | } 79 | return item.*; 80 | } 81 | }; 82 | } 83 | 84 | fn isNativeJSONType(comptime T: type) bool { 85 | return switch (T) { 86 | i64, f64, []const u8, bool => true, 87 | else => false, 88 | }; 89 | } 90 | 91 | fn assignRawType(destination: var, comptime fieldName: []const u8, comptime fieldType: type, object: var) void { 92 | switch (fieldType) { 93 | i64 => @field(destination, fieldName) = object.value.Integer, 94 | f64 => @field(destination, fieldName) = object.value.Float, 95 | bool => @field(destination, fieldName) = object.value.Bool, 96 | []const u8 => @field(destination, fieldName) = object.value.String, 97 | else => @compileError(@typeName(fieldType) ++ " is not supported"), 98 | } 99 | } 100 | 101 | const TestSkeleton = struct { 102 | int: i64, 103 | isCool: bool, 104 | float: f64, 105 | language: []const u8, 106 | optional: ?bool, 107 | array: []f64, 108 | 109 | const Bar = struct { 110 | nested: []const u8, 111 | }; 112 | complex: Bar, 113 | 114 | const Baz = struct { 115 | foo: []const u8, 116 | }; 117 | veryComplex: []Baz, 118 | }; 119 | const TestStruct = Decodable(TestSkeleton); 120 | 121 | test "JSON Mapping works" { 122 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 123 | defer arena.deinit(); 124 | const allocator = &arena.allocator; 125 | var p = std.json.Parser.init(allocator, false); 126 | defer p.deinit(); 127 | const tree = try p.parse("{\"TEST_EXPECTED\": 1}"); 128 | const S = Decodable(struct { 129 | expected: i64, 130 | pub const json_expected: []const u8 = "TEST_EXPECTED"; 131 | }); 132 | const s = try S.fromJson(.{}, allocator, tree.root.Object); 133 | } 134 | 135 | test "NoIgnore works" { 136 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 137 | defer arena.deinit(); 138 | const allocator = &arena.allocator; 139 | var p = std.json.Parser.init(allocator, false); 140 | defer p.deinit(); 141 | const tree = try p.parse("{}"); 142 | const S = Decodable(struct { 143 | expected: i64, 144 | }); 145 | const attempt = S.fromJson(.{}, allocator, tree.root.Object); 146 | std.testing.expectError(error.MissingField, attempt); 147 | } 148 | 149 | test "Decode works" { 150 | var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 151 | defer arena.deinit(); 152 | const allocator = &arena.allocator; 153 | const json = 154 | \\{ 155 | \\ "int": 420, 156 | \\ "float": 3.14, 157 | \\ "isCool": true, 158 | \\ "language": "zig", 159 | \\ "optional": null, 160 | \\ "array": [66.6, 420.420, 69.69], 161 | \\ "complex": { 162 | \\ "nested": "zig" 163 | \\ }, 164 | \\ "veryComplex": [ 165 | \\ { 166 | \\ "foo": "zig" 167 | \\ }, { 168 | \\ "foo": "rocks" 169 | \\ } 170 | \\ ] 171 | \\} 172 | ; 173 | var p = std.json.Parser.init(allocator, false); 174 | defer p.deinit(); 175 | const tree = try p.parse(json); 176 | const testStruct = try TestStruct.fromJson(.{}, allocator, tree.root.Object); 177 | testing.expectEqual(testStruct.int, 420); 178 | testing.expectEqual(testStruct.float, 3.14); 179 | testing.expectEqual(testStruct.isCool, true); 180 | testing.expect(mem.eql(u8, testStruct.language, "zig")); 181 | testing.expectEqual(testStruct.optional, null); 182 | testing.expect(mem.eql(u8, testStruct.complex.nested, "zig")); 183 | testing.expectEqual(testStruct.array[0], 66.6); 184 | testing.expectEqual(testStruct.array[1], 420.420); 185 | testing.expectEqual(testStruct.array[2], 69.69); 186 | testing.expect(mem.eql(u8, testStruct.veryComplex[0].foo, "zig")); 187 | testing.expect(mem.eql(u8, testStruct.veryComplex[1].foo, "rocks")); 188 | } 189 | --------------------------------------------------------------------------------