// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The few places where `std.json`'s defaults and Paperless's JSON disagree. //! //! Three of them, each small: //! //! * **Integer enums.** Paperless sends `matching_algorithm` and a saved //! view's `rule_type` as numbers. `std.json` reads a number into an enum //! happily, but writes an enum back as its tag name, which Paperless //! refuses. `writeIntEnum` writes the number. //! * **Enums that grow.** A task's `task_type`, a custom field's //! `data_type`: each release adds a value or two, and a client that fails //! to parse a whole page of tasks because one of them is of a kind it has //! not heard of is worse than one that says `unknown`. `parseOpenEnum` reads //! a name it does not know as `.unknown`. //! * **Setting a field to null.** A `PATCH` sends the fields it changes and //! no others, so "leave this alone" and "clear this" are two different //! requests, and an optional can only say one of them. `Clearable` is the //! other. const std = @import("std"); const Allocator = std.mem.Allocator; const testing = std.testing; /// The options every request body is written with: a field left `null` is /// a field not sent, which is what makes one struct serve as both a `PUT` /// body and a `PATCH` body. pub const stringify_options: std.json.Stringify.Options = .{ .emit_null_optional_fields = false, }; /// The options every response is read with. Unknown fields are ignored /// because every Paperless release adds some, and a client should keep /// working against a server newer than itself; `alloc_always` because the /// response buffer is freed as soon as the parse is done. pub const parse_options: std.json.ParseOptions = .{ .ignore_unknown_fields = true, .allocate = .alloc_always, }; /// A field in an update that can be left alone, set, or cleared. /// /// Used as `?Clearable(T)`: `null` leaves the field out of the request, /// `.{ .set = x }` sends `x`, and `.clear` sends JSON `null` -- which is how /// a document loses its correspondent. pub fn Clearable(comptime T: type) type { return union(enum) { set: T, clear, pub fn jsonStringify(self: @This(), jw: anytype) !void { switch (self) { .set => |v| try jw.write(v), .clear => try jw.write(null), } } }; } /// `jsonStringify` for an enum whose wire form is its integer value. An enum /// declares it as /// /// ```zig /// pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { /// return json.writeIntEnum(self, jw); /// } /// ``` pub fn writeIntEnum(value: anytype, jw: *std.json.Stringify) std.json.Stringify.Error!void { try jw.write(@backingInt(value)); } /// `jsonParse` for a string enum with an `unknown` tag, which is what a name /// the enum does not have is read as. pub fn parseOpenEnum( comptime E: type, allocator: Allocator, source: anytype, options: std.json.ParseOptions, ) std.json.ParseError(@TypeOf(source.*))!E { const token = try source.nextAllocMax(allocator, .alloc_if_needed, options.max_value_len.?); const slice = switch (token) { inline .string, .allocated_string => |slice| slice, else => return error.UnexpectedToken, }; defer switch (token) { .allocated_string => |owned| allocator.free(owned), else => {}, }; return std.meta.stringToEnum(E, slice) orelse .unknown; } test Clearable { const Update = struct { title: ?[]const u8 = null, correspondent: ?Clearable(u64) = null, }; var out: std.Io.Writer.Allocating = .init(testing.allocator); defer out.deinit(); try std.json.Stringify.value(Update{ .correspondent = .clear }, stringify_options, &out.writer); try testing.expectEqualStrings("{\"correspondent\":null}", out.written()); out.clearRetainingCapacity(); try std.json.Stringify.value(Update{ .title = "t", .correspondent = .{ .set = 4 } }, stringify_options, &out.writer); try testing.expectEqualStrings("{\"title\":\"t\",\"correspondent\":4}", out.written()); out.clearRetainingCapacity(); try std.json.Stringify.value(Update{}, stringify_options, &out.writer); try testing.expectEqualStrings("{}", out.written()); } test parseOpenEnum { const Kind = enum { a, b, unknown, pub fn jsonParse(allocator: Allocator, source: anytype, options: std.json.ParseOptions) !@This() { return parseOpenEnum(@This(), allocator, source, options); } }; const parsed = try std.json.parseFromSlice([]Kind, testing.allocator, "[\"a\",\"b\",\"zebra\"]", parse_options); defer parsed.deinit(); try testing.expectEqualSlices(Kind, &.{ .a, .b, .unknown }, parsed.value); } test writeIntEnum { const Algorithm = enum(u8) { none = 0, any = 1, _, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { return writeIntEnum(self, jw); } }; var out: std.Io.Writer.Allocating = .init(testing.allocator); defer out.deinit(); try std.json.Stringify.value([_]Algorithm{ .any, @fromBackingInt(9) }, .{}, &out.writer); try testing.expectEqualStrings("[1,9]", out.written()); const parsed = try std.json.parseFromSlice(Algorithm, testing.allocator, "1", parse_options); defer parsed.deinit(); try testing.expectEqual(Algorithm.any, parsed.value); }