diff --git a/src/aws.zig b/src/aws.zig index 20ff52c..df8ef29 100644 --- a/src/aws.zig +++ b/src/aws.zig @@ -8,6 +8,7 @@ const case = @import("case.zig"); const date = @import("date.zig"); const servicemodel = @import("servicemodel.zig"); const xml_shaper = @import("xml_shaper.zig"); +const xml_serializer = @import("xml_serializer.zig"); const scoped_log = std.log.scoped(.aws); diff --git a/src/xml_serializer.zig b/src/xml_serializer.zig new file mode 100644 index 0000000..23b2f59 --- /dev/null +++ b/src/xml_serializer.zig @@ -0,0 +1,663 @@ +const std = @import("std"); +const mem = std.mem; +const Allocator = mem.Allocator; + +/// Options for controlling XML serialization behavior +pub const StringifyOptions = struct { + /// Controls whitespace insertion for easier human readability + whitespace: Whitespace = .minified, + + /// Should optional fields with null value be written? + emit_null_optional_fields: bool = true, + + // TODO: Implement + /// Arrays/slices of u8 are typically encoded as strings. This option emits them as arrays of numbers instead. Does not affect calls to objectField*(). + emit_strings_as_arrays: bool = false, + + /// Controls whether to include XML declaration at the beginning + include_declaration: bool = true, + + /// Root element name to use when serializing a value that doesn't have a natural name + root_name: ?[]const u8 = "root", + + /// Function to determine the element name for an array item based on the element + /// name of the array containing the elements. See arrayElementPluralToSingluarTransformation + /// and arrayElementNoopTransformation functions for examples + arrayElementNameConversion: *const fn (allocator: std.mem.Allocator, name: ?[]const u8) error{OutOfMemory}!?[]const u8 = arrayElementPluralToSingluarTransformation, + + pub const Whitespace = enum { + minified, + indent_1, + indent_2, + indent_3, + indent_4, + indent_8, + indent_tab, + }; +}; + +/// Error set for XML serialization +pub const XmlSerializeError = error{ + /// Unsupported type for XML serialization + UnsupportedType, + /// Out of memory + OutOfMemory, + /// Write error + WriteError, +}; + +/// Serializes a value to XML and writes it to the provided writer +pub fn stringify( + value: anytype, + options: StringifyOptions, + writer: anytype, +) !void { + // Write XML declaration if requested + if (options.include_declaration) + try writer.writeAll("\n"); + + // Start serialization with the root element + const root_name = options.root_name; + try serializeValue(value, root_name, options, writer.any(), 0); +} + +/// Serializes a value to XML and returns an allocated string +pub fn stringifyAlloc( + allocator: Allocator, + value: anytype, + options: StringifyOptions, +) ![]u8 { + var list = std.ArrayList(u8).init(allocator); + errdefer list.deinit(); + + try stringify(value, options, list.writer()); + return list.toOwnedSlice(); +} + +/// Internal function to serialize a value with proper indentation +fn serializeValue( + value: anytype, + element_name: ?[]const u8, + options: StringifyOptions, + writer: anytype, + depth: usize, +) !void { + const T = @TypeOf(value); + + try writeIndent(writer, depth, options.whitespace); + + // const write_outer_element = + // @typeInfo(T) != .optional or + // options.emit_strings_as_arrays == false or + // (@typeInfo(T) == .optional and element_name != null) or + // (options.emit_strings_as_arrays and (@typeInfo(T) != .array or @typeInfo(T).array.child != u8)); + // Start element tag + if (@typeInfo(T) != .optional and @typeInfo(T) != .array) { + if (element_name) |n| { + try writer.writeAll("<"); + try writer.writeAll(n); + try writer.writeAll(">"); + } + } + + // Handle different types + switch (@typeInfo(T)) { + .bool => try writer.writeAll(if (value) "true" else "false"), + .int, .comptime_int, .float, .comptime_float => try writer.print("{}", .{value}), + .pointer => |ptr_info| { + switch (ptr_info.size) { + .one => { + // We don't want to write the opening tag a second time, so + // we will pass null, then come back and close before returning + // + // ...but...in the event of a *[]const u8, we do want to pass that in, + // but only if emit_strings_as_arrays is true + const child_ti = @typeInfo(ptr_info.child); + const el_name = if (options.emit_strings_as_arrays and child_ti == .array and child_ti.array.child == u8) + element_name + else + null; + try serializeValue(value.*, el_name, options, writer, depth); + try writeClose(writer, element_name); + return; + }, + .slice => { + if (ptr_info.child == u8) { + // String type + try serializeString(writer, element_name, value, options, depth); + } else { + // Array of values + if (options.whitespace != .minified) { + try writer.writeByte('\n'); + } + + var buf: [256]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buf); + const alloc = fba.allocator(); + const item_name = try options.arrayElementNameConversion(alloc, element_name); + + for (value) |item| { + try serializeValue(item, item_name, options, writer, depth + 1); + if (options.whitespace != .minified) { + try writer.writeByte('\n'); + } + } + + try writeIndent(writer, depth, options.whitespace); + } + }, + else => return error.UnsupportedType, + } + }, + .array => |array_info| { + if (!options.emit_strings_as_arrays or array_info.child != u8) { + if (element_name) |n| { + try writer.writeAll("<"); + try writer.writeAll(n); + try writer.writeAll(">"); + } + } + if (array_info.child == u8) { + // Fixed-size string + const slice = &value; + try serializeString(writer, element_name, slice, options, depth); + } else { + // Fixed-size array + if (options.whitespace != .minified) { + try writer.writeByte('\n'); + } + + var buf: [256]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buf); + const alloc = fba.allocator(); + const item_name = try options.arrayElementNameConversion(alloc, element_name); + + for (value) |item| { + try serializeValue(item, item_name, options, writer, depth + 1); + if (options.whitespace != .minified) { + try writer.writeByte('\n'); + } + } + + try writeIndent(writer, depth, options.whitespace); + } + if (!options.emit_strings_as_arrays or array_info.child != u8) + try writeClose(writer, element_name); + return; + }, + .@"struct" => |struct_info| { + if (options.whitespace != .minified) { + try writer.writeByte('\n'); + } + + inline for (struct_info.fields) |field| { + const field_name = + if (std.meta.hasFn(T, "fieldNameFor")) + value.fieldNameFor(field.name) + else + field.name; // TODO: field mapping + + try serializeValue( + @field(value, field.name), + field_name, + options, + writer, + depth + 1, + ); + + if (options.whitespace != .minified) { + try writer.writeByte('\n'); + } + } + + try writeIndent(writer, depth, options.whitespace); + }, + .optional => { + if (options.emit_null_optional_fields or value != null) { + if (element_name) |n| { + try writer.writeAll("<"); + try writer.writeAll(n); + try writer.writeAll(">"); + } + } + if (value) |payload| { + try serializeValue(payload, null, options, writer, depth); + } else { + // For null values, we'll write an empty element + // We've already written the opening tag, so just close it immediately + if (options.emit_null_optional_fields) + try writeClose(writer, element_name); + return; + } + }, + .null => { + // Empty element + }, + .@"enum" => { + try std.fmt.format(writer, "{s}", .{@tagName(value)}); + }, + .@"union" => |union_info| { + if (union_info.tag_type) |_| { + inline for (union_info.fields) |field| { + if (@field(std.meta.Tag(T), field.name) == std.meta.activeTag(value)) { + try serializeValue( + @field(value, field.name), + field.name, + options, + writer, + depth, + ); + break; + } + } + } else { + return error.UnsupportedType; + } + }, + else => return error.UnsupportedType, + } + + try writeClose(writer, element_name); +} + +fn writeClose(writer: anytype, element_name: ?[]const u8) !void { + // Close element tag + if (element_name) |n| { + try writer.writeAll(""); + } +} + +/// Writes indentation based on depth and indent level +fn writeIndent(writer: anytype, depth: usize, whitespace: StringifyOptions.Whitespace) @TypeOf(writer).Error!void { + var char: u8 = ' '; + const n_chars = switch (whitespace) { + .minified => return, + .indent_1 => 1 * depth, + .indent_2 => 2 * depth, + .indent_3 => 3 * depth, + .indent_4 => 4 * depth, + .indent_8 => 8 * depth, + .indent_tab => blk: { + char = '\t'; + break :blk depth; + }, + }; + try writer.writeByteNTimes(char, n_chars); +} + +fn serializeString( + writer: anytype, + element_name: ?[]const u8, + value: []const u8, + options: StringifyOptions, + depth: usize, +) @TypeOf(writer).Error!void { + if (options.emit_strings_as_arrays) { + // if (true) return error.seestackrun; + for (value) |c| { + try writeIndent(writer, depth + 1, options.whitespace); + + var buf: [256]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buf); + const alloc = fba.allocator(); + const item_name = try options.arrayElementNameConversion(alloc, element_name); + if (item_name) |n| { + try writer.writeAll("<"); + try writer.writeAll(n); + try writer.writeAll(">"); + } + try writer.print("{d}", .{c}); + try writeClose(writer, item_name); + if (options.whitespace != .minified) { + try writer.writeByte('\n'); + } + } + return; + } + try escapeString(writer, value); +} +/// Escapes special characters in XML strings +fn escapeString(writer: anytype, value: []const u8) @TypeOf(writer).Error!void { + for (value) |c| { + switch (c) { + '&' => try writer.writeAll("&"), + '<' => try writer.writeAll("<"), + '>' => try writer.writeAll(">"), + '"' => try writer.writeAll("""), + '\'' => try writer.writeAll("'"), + else => try writer.writeByte(c), + } + } +} + +/// Does no transformation on the input array +pub fn arrayElementNoopTransformation(allocator: std.mem.Allocator, name: ?[]const u8) !?[]const u8 { + _ = allocator; + return name; +} + +/// Attempts to convert a plural name to singular for array items +pub fn arrayElementPluralToSingluarTransformation(allocator: std.mem.Allocator, name: ?[]const u8) !?[]const u8 { + if (name == null or name.?.len < 3) return name; + + const n = name.?; + // There are a ton of these words, I'm just adding two for now + // https://wordmom.com/nouns/end-e + const es_exceptions = &[_][]const u8{ + "types", + "bytes", + }; + for (es_exceptions) |exception| { + if (std.mem.eql(u8, exception, n)) { + return n[0 .. n.len - 1]; + } + } + // Very basic English pluralization rules + if (std.mem.endsWith(u8, n, "s")) { + if (std.mem.endsWith(u8, n, "ies")) { + // e.g., "entries" -> "entry" + return try std.mem.concat(allocator, u8, &[_][]const u8{ n[0 .. n.len - 3], "y" }); + } else if (std.mem.endsWith(u8, n, "es")) { + return n[0 .. n.len - 2]; // e.g., "boxes" -> "box" + } else { + return n[0 .. n.len - 1]; // e.g., "items" -> "item" + } + } + + return name; // Not recognized as plural +} + +// Tests +test "stringify basic types" { + const testing = std.testing; + const allocator = testing.allocator; + + // Test boolean + { + const result = try stringifyAlloc(allocator, true, .{}); + defer allocator.free(result); + try testing.expectEqualStrings("\ntrue", result); + } + + // Test comptime integer + { + const result = try stringifyAlloc(allocator, 42, .{}); + defer allocator.free(result); + try testing.expectEqualStrings("\n42", result); + } + + // Test integer + { + const result = try stringifyAlloc(allocator, @as(usize, 42), .{}); + defer allocator.free(result); + try testing.expectEqualStrings("\n42", result); + } + + // Test float + { + const result = try stringifyAlloc(allocator, 3.14, .{}); + defer allocator.free(result); + try testing.expectEqualStrings("\n3.14e0", result); + } + + // Test string + { + const result = try stringifyAlloc(allocator, "hello", .{}); + // @compileLog(@typeInfo(@TypeOf("hello")).pointer.size); + // @compileLog(@typeName(@typeInfo(@TypeOf("hello")).pointer.child)); + defer allocator.free(result); + try testing.expectEqualStrings("\nhello", result); + } + + // Test string with special characters + { + const result = try stringifyAlloc(allocator, "hello & world < > \" '", .{}); + defer allocator.free(result); + try testing.expectEqualStrings("\nhello & world < > " '", result); + } +} + +test "stringify arrays" { + const testing = std.testing; + const allocator = testing.allocator; + + // Test array of integers + { + const arr = [_]i32{ 1, 2, 3 }; + const result = try stringifyAlloc(allocator, arr, .{}); + defer allocator.free(result); + try testing.expectEqualStrings("\n123", result); + } + + // Test array of strings + { + const arr = [_][]const u8{ "one", "two", "three" }; + const result = try stringifyAlloc(allocator, arr, .{}); + defer allocator.free(result); + try testing.expectEqualStrings("\nonetwothree", result); + } + + // Test array with custom root name + { + const arr = [_]i32{ 1, 2, 3 }; + const result = try stringifyAlloc(allocator, arr, .{ .root_name = "items" }); + defer allocator.free(result); + try testing.expectEqualStrings("\n123", result); + } +} + +test "stringify structs" { + const testing = std.testing; + const allocator = testing.allocator; + + const Person = struct { + name: []const u8, + age: u32, + is_active: bool, + }; + + // Test basic struct + { + const person = Person{ + .name = "John", + .age = 30, + .is_active = true, + }; + + const result = try stringifyAlloc(allocator, person, .{}); + defer allocator.free(result); + try testing.expectEqualStrings("\nJohn30true", result); + } + + // Test struct with pretty printing + { + const person = Person{ + .name = "John", + .age = 30, + .is_active = true, + }; + + const result = try stringifyAlloc(allocator, person, .{ .whitespace = .indent_4 }); + defer allocator.free(result); + try testing.expectEqualStrings("\n\n John\n 30\n true\n", result); + } + + // Test nested struct + { + const Address = struct { + street: []const u8, + city: []const u8, + }; + + const PersonWithAddress = struct { + name: []const u8, + address: Address, + }; + + const person = PersonWithAddress{ + .name = "John", + .address = Address{ + .street = "123 Main St", + .city = "Anytown", + }, + }; + + const result = try stringifyAlloc(allocator, person, .{ .whitespace = .indent_4 }); + defer allocator.free(result); + try testing.expectEqualStrings("\n\n John\n
\n 123 Main St\n Anytown\n
\n
", result); + } +} + +test "stringify optional values" { + const testing = std.testing; + const allocator = testing.allocator; + + const Person = struct { + name: []const u8, + middle_name: ?[]const u8, + }; + + // Test with present optional + { + const person = Person{ + .name = "John", + .middle_name = "Robert", + }; + + const result = try stringifyAlloc(allocator, person, .{}); + defer allocator.free(result); + try testing.expectEqualStrings("\nJohnRobert", result); + } + + // Test with null optional + { + const person = Person{ + .name = "John", + .middle_name = null, + }; + + const result = try stringifyAlloc(allocator, person, .{}); + defer allocator.free(result); + try testing.expectEqualStrings("\nJohn", result); + } +} + +test "stringify optional values with emit_null_optional_fields == false" { + const testing = std.testing; + const allocator = testing.allocator; + + const Person = struct { + name: []const u8, + middle_name: ?[]const u8, + }; + + // Test with present optional + { + const person = Person{ + .name = "John", + .middle_name = "Robert", + }; + + const result = try stringifyAlloc(allocator, person, .{ .emit_null_optional_fields = false }); + defer allocator.free(result); + try testing.expectEqualStrings("\nJohnRobert", result); + } + + // Test with null optional + { + const person = Person{ + .name = "John", + .middle_name = null, + }; + + const result = try stringifyAlloc(allocator, person, .{ .emit_null_optional_fields = false }); + defer allocator.free(result); + try testing.expectEqualStrings("\nJohn", result); + } +} + +test "stringify with custom options" { + const testing = std.testing; + const allocator = testing.allocator; + + const Person = struct { + first_name: []const u8, + last_name: []const u8, + }; + + const person = Person{ + .first_name = "John", + .last_name = "Doe", + }; + + // Test without XML declaration + { + const result = try stringifyAlloc(allocator, person, .{ .include_declaration = false }); + defer allocator.free(result); + try testing.expectEqualStrings("JohnDoe", result); + } + + // Test with custom root name + { + const result = try stringifyAlloc(allocator, person, .{ .root_name = "person" }); + defer allocator.free(result); + try testing.expectEqualStrings("\nJohnDoe", result); + } + + // Test with custom indent level + { + const result = try stringifyAlloc(allocator, person, .{ .whitespace = .indent_2 }); + defer allocator.free(result); + try testing.expectEqualStrings( + \\ + \\ + \\ John + \\ Doe + \\ + , result); + } + + // Test with output []u8 as array + { + // pointer, size 1, child == .array, child.array.child == u8 + // @compileLog(@typeInfo(@typeInfo(@TypeOf("foo")).pointer.child)); + const result = try stringifyAlloc(allocator, "foo", .{ .emit_strings_as_arrays = true, .root_name = "bytes" }); + defer allocator.free(result); + try testing.expectEqualStrings("\n102111111", result); + } +} + +test "structs with custom field names" { + const testing = std.testing; + const allocator = testing.allocator; + + const Person = struct { + first_name: []const u8, + last_name: []const u8, + + pub fn fieldNameFor(_: @This(), comptime field_name: []const u8) []const u8 { + if (std.mem.eql(u8, field_name, "first_name")) return "GivenName"; + if (std.mem.eql(u8, field_name, "last_name")) return "FamilyName"; + unreachable; + } + }; + + const person = Person{ + .first_name = "John", + .last_name = "Doe", + }; + + { + const result = try stringifyAlloc(allocator, person, .{ .whitespace = .indent_2 }); + defer allocator.free(result); + try testing.expectEqualStrings( + \\ + \\ + \\ John + \\ Doe + \\ + , result); + } +}