From 859fcd3d1e2ae3a9b5de6b8df23ad0739a4f31bd Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Wed, 20 May 2026 17:15:11 +0200 Subject: [PATCH 01/15] model: add Parameter.content_type for body media type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the declared media type on body parameters so converters can record it at parse time and the generator can route binary/text payloads without sniffing schemas. The field is allocator-owned and freed in Parameter.deinit. No behavior change yet — converters and generator are wired in subsequent commits. --- src/models/common/document.zig | 4 ++++ src/tests.zig | 2 ++ src/tests/binary_payload_tests.zig | 38 ++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 src/tests/binary_payload_tests.zig diff --git a/src/models/common/document.zig b/src/models/common/document.zig index fe663f0..dc7a5c3 100644 --- a/src/models/common/document.zig +++ b/src/models/common/document.zig @@ -141,8 +141,12 @@ pub const Parameter = struct { schema: ?Schema = null, type: ?SchemaType = null, format: ?[]const u8 = null, + /// Declared media type for body parameters. Owned by `allocator` (duped at + /// converter time) when non-null. Always null for non-body parameters. + content_type: ?[]const u8 = null, pub fn deinit(self: *Parameter, allocator: std.mem.Allocator) void { if (self.schema) |*schema| schema.deinit(allocator); + if (self.content_type) |ct| allocator.free(ct); } }; diff --git a/src/tests.zig b/src/tests.zig index 9f718c8..2260bf1 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -8,6 +8,7 @@ const test_input_loader = @import("tests/test_input_loader.zig"); const yaml_loader_tests = @import("tests/yaml_loader_tests.zig"); const resource_wrapper_tests = @import("tests/resource_wrapper_tests.zig"); const model_typing_tests = @import("tests/model_typing_tests.zig"); +const binary_payload_tests = @import("tests/binary_payload_tests.zig"); const generator = @import("generator.zig"); comptime { _ = openapi_v3_tests; @@ -20,5 +21,6 @@ comptime { _ = yaml_loader_tests; _ = resource_wrapper_tests; _ = model_typing_tests; + _ = binary_payload_tests; _ = generator; } diff --git a/src/tests/binary_payload_tests.zig b/src/tests/binary_payload_tests.zig new file mode 100644 index 0000000..cf0f815 --- /dev/null +++ b/src/tests/binary_payload_tests.zig @@ -0,0 +1,38 @@ +const std = @import("std"); +const testing = std.testing; +const test_utils = @import("test_utils.zig"); +const document = @import("../models/common/document.zig"); +const Parameter = document.Parameter; +const ParameterLocation = document.ParameterLocation; + +test "Parameter.content_type owns and frees its allocation" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + defer std.debug.assert(gpa.deinit() == .ok); + + var param = Parameter{ + .name = "requestBody", + .location = .body, + .required = true, + .content_type = try allocator.dupe(u8, "application/octet-stream"), + }; + defer param.deinit(allocator); + + try testing.expect(param.content_type != null); + try testing.expectEqualStrings("application/octet-stream", param.content_type.?); +} + +test "Parameter.content_type defaults to null for non-body parameters" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + defer std.debug.assert(gpa.deinit() == .ok); + + var param = Parameter{ + .name = "petId", + .location = .path, + .required = true, + }; + defer param.deinit(allocator); + + try testing.expect(param.content_type == null); +} From 022e99d1c029f31ab2ba66b3be9fc8665b595289 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Wed, 20 May 2026 17:16:56 +0200 Subject: [PATCH 02/15] converter(v3.0): capture request-body content-type Records the selected media type on Parameter.content_type with the existing JSON-first preference rule (application/json wins; then any *+json suffix; then the first declared media type). Strings are duped into the unified allocator. ref request bodies stay null pending a future ref-resolution follow-up. No generator behavior change yet. --- .../converters/openapi_converter.zig | 24 +++ src/tests/binary_payload_tests.zig | 161 ++++++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/src/generators/converters/openapi_converter.zig b/src/generators/converters/openapi_converter.zig index 6e29c4b..01f57f2 100644 --- a/src/generators/converters/openapi_converter.zig +++ b/src/generators/converters/openapi_converter.zig @@ -325,18 +325,32 @@ pub const OpenApiConverter = struct { fn convertRequestBody(self: *OpenApiConverter, requestBody: *const RequestBody3) !Parameter { var mut_request_body = requestBody.*; var schema: ?Schema = null; + var selected_key: ?[]const u8 = null; if (mut_request_body.content.get("application/json")) |media_type| { + selected_key = "application/json"; if (media_type.schema) |schema_or_ref| { schema = try self.convertSchemaOrReference(schema_or_ref); } + } else if (selectJsonSuffixKey(mut_request_body.content)) |key| { + if (mut_request_body.content.get(key)) |media_type| { + selected_key = key; + if (media_type.schema) |schema_or_ref| { + schema = try self.convertSchemaOrReference(schema_or_ref); + } + } } else if (mut_request_body.content.count() > 0) { var it = mut_request_body.content.iterator(); if (it.next()) |entry| { + selected_key = entry.key_ptr.*; if (entry.value_ptr.schema) |schema_or_ref| { schema = try self.convertSchemaOrReference(schema_or_ref); } } } + const content_type: ?[]const u8 = if (selected_key) |k| + (if (k.len == 0) null else try self.allocator.dupe(u8, k)) + else + null; return Parameter{ .name = "body", .location = .body, @@ -345,9 +359,19 @@ pub const OpenApiConverter = struct { .schema = schema, .type = null, .format = null, + .content_type = content_type, }; } + fn selectJsonSuffixKey(content: std.StringHashMap(@import("../../models/v3.0/media.zig").MediaType)) ?[]const u8 { + var it = content.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + if (std.mem.endsWith(u8, key, "+json")) return key; + } + return null; + } + fn convertParameters(self: *OpenApiConverter, parameters: []const ParameterOrReference3) ![]Parameter { var converted_params = try self.allocator.alloc(Parameter, parameters.len); for (parameters, 0..) |param_ref, i| { diff --git a/src/tests/binary_payload_tests.zig b/src/tests/binary_payload_tests.zig index cf0f815..075939b 100644 --- a/src/tests/binary_payload_tests.zig +++ b/src/tests/binary_payload_tests.zig @@ -4,6 +4,40 @@ const test_utils = @import("test_utils.zig"); const document = @import("../models/common/document.zig"); const Parameter = document.Parameter; const ParameterLocation = document.ParameterLocation; +const UnifiedDocument = document.UnifiedDocument; +const Operation = document.Operation; +const models = @import("../models.zig"); +const OpenApiConverter = @import("../generators/converters/openapi_converter.zig").OpenApiConverter; + +fn loadOpenApiDocument(allocator: std.mem.Allocator, file_path: []const u8) !models.OpenApiDocument { + const file_contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, file_path, allocator, .unlimited); + defer allocator.free(file_contents); + return try models.OpenApiDocument.parseFromJson(allocator, file_contents); +} + +fn parseAndConvertV3(allocator: std.mem.Allocator, json: []const u8) !UnifiedDocument { + var parsed = try models.OpenApiDocument.parseFromJson(allocator, json); + defer parsed.deinit(allocator); + var converter = OpenApiConverter.init(allocator); + return converter.convert(parsed); +} + +fn findOperation(unified: *const UnifiedDocument, path: []const u8, method: enum { get, post, put }) ?Operation { + const path_item = unified.paths.get(path) orelse return null; + return switch (method) { + .get => path_item.get, + .post => path_item.post, + .put => path_item.put, + }; +} + +fn findBodyParameter(op: Operation) ?Parameter { + const params = op.parameters orelse return null; + for (params) |p| { + if (p.location == .body) return p; + } + return null; +} test "Parameter.content_type owns and frees its allocation" { var gpa = test_utils.createTestAllocator(); @@ -36,3 +70,130 @@ test "Parameter.content_type defaults to null for non-body parameters" { try testing.expect(param.content_type == null); } + +test "v3.0 converter :: octet-stream uploadFile body captures content_type" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + + var parsed = try loadOpenApiDocument(allocator, "openapi/v3.0/petstore.json"); + defer parsed.deinit(allocator); + var converter = OpenApiConverter.init(allocator); + var unified = try converter.convert(parsed); + defer unified.deinit(allocator); + + const op = findOperation(&unified, "/pet/{petId}/uploadImage", .post) orelse return error.OperationNotFound; + const body = findBodyParameter(op) orelse return error.BodyNotFound; + try testing.expect(body.content_type != null); + try testing.expectEqualStrings("application/octet-stream", body.content_type.?); +} + +test "v3.0 converter :: addPet body prefers application/json content_type" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + + var parsed = try loadOpenApiDocument(allocator, "openapi/v3.0/petstore.json"); + defer parsed.deinit(allocator); + var converter = OpenApiConverter.init(allocator); + var unified = try converter.convert(parsed); + defer unified.deinit(allocator); + + const op = findOperation(&unified, "/pet", .post) orelse return error.OperationNotFound; + const body = findBodyParameter(op) orelse return error.BodyNotFound; + try testing.expect(body.content_type != null); + try testing.expectEqualStrings("application/json", body.content_type.?); +} + +test "v3.0 converter :: JSON wins when both JSON and XML present" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + + const spec = + \\{ + \\ "openapi": "3.0.3", + \\ "info": { "title": "T", "version": "1" }, + \\ "paths": { + \\ "/x": { + \\ "post": { + \\ "operationId": "doX", + \\ "requestBody": { + \\ "content": { + \\ "application/xml": { "schema": { "type": "string" } }, + \\ "application/json": { "schema": { "type": "object" } } + \\ } + \\ }, + \\ "responses": { "200": { "description": "ok" } } + \\ } + \\ } + \\ } + \\} + ; + var unified = try parseAndConvertV3(allocator, spec); + defer unified.deinit(allocator); + + const op = findOperation(&unified, "/x", .post) orelse return error.OperationNotFound; + const body = findBodyParameter(op) orelse return error.BodyNotFound; + try testing.expectEqualStrings("application/json", body.content_type.?); +} + +test "v3.0 converter :: XML-only body falls back to application/xml" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + + const spec = + \\{ + \\ "openapi": "3.0.3", + \\ "info": { "title": "T", "version": "1" }, + \\ "paths": { + \\ "/x": { + \\ "post": { + \\ "operationId": "doX", + \\ "requestBody": { + \\ "content": { + \\ "application/xml": { "schema": { "type": "string" } } + \\ } + \\ }, + \\ "responses": { "200": { "description": "ok" } } + \\ } + \\ } + \\ } + \\} + ; + var unified = try parseAndConvertV3(allocator, spec); + defer unified.deinit(allocator); + + const op = findOperation(&unified, "/x", .post) orelse return error.OperationNotFound; + const body = findBodyParameter(op) orelse return error.BodyNotFound; + try testing.expectEqualStrings("application/xml", body.content_type.?); +} + +test "v3.0 converter :: vendor +json suffix selected over non-json" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + + const spec = + \\{ + \\ "openapi": "3.0.3", + \\ "info": { "title": "T", "version": "1" }, + \\ "paths": { + \\ "/x": { + \\ "post": { + \\ "operationId": "doX", + \\ "requestBody": { + \\ "content": { + \\ "application/xml": { "schema": { "type": "string" } }, + \\ "application/vnd.acme+json": { "schema": { "type": "object" } } + \\ } + \\ }, + \\ "responses": { "200": { "description": "ok" } } + \\ } + \\ } + \\ } + \\} + ; + var unified = try parseAndConvertV3(allocator, spec); + defer unified.deinit(allocator); + + const op = findOperation(&unified, "/x", .post) orelse return error.OperationNotFound; + const body = findBodyParameter(op) orelse return error.BodyNotFound; + try testing.expectEqualStrings("application/vnd.acme+json", body.content_type.?); +} From 031e1ebe11fb4af853f7450878c04b6949697473 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Wed, 20 May 2026 17:18:43 +0200 Subject: [PATCH 03/15] converter(v3.1, v3.2): capture request-body content-type Mirrors the v3.0 selection rule on Parameter.content_type for the v3.1 and v3.2 converters: application/json wins, then any *+json suffix, then the first declared media type. Strings are duped into the unified allocator. Adds smoke tests covering octet-stream and JSON-vs-XML. --- .../converters/openapi31_converter.zig | 24 +++++++ .../converters/openapi32_converter.zig | 24 +++++++ src/tests/binary_payload_tests.zig | 71 +++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/src/generators/converters/openapi31_converter.zig b/src/generators/converters/openapi31_converter.zig index 7696467..ab2249f 100644 --- a/src/generators/converters/openapi31_converter.zig +++ b/src/generators/converters/openapi31_converter.zig @@ -586,18 +586,32 @@ pub const OpenApi31Converter = struct { fn convertRequestBody(self: *OpenApi31Converter, requestBody: *const RequestBody31) !Parameter { var mut_request_body = requestBody.*; var schema: ?Schema = null; + var selected_key: ?[]const u8 = null; if (mut_request_body.content.get("application/json")) |media_type| { + selected_key = "application/json"; if (media_type.schema) |schema_or_ref| { schema = try self.convertSchemaOrReference(schema_or_ref); } + } else if (selectJsonSuffixKey31(mut_request_body.content)) |key| { + if (mut_request_body.content.get(key)) |media_type| { + selected_key = key; + if (media_type.schema) |schema_or_ref| { + schema = try self.convertSchemaOrReference(schema_or_ref); + } + } } else if (mut_request_body.content.count() > 0) { var it = mut_request_body.content.iterator(); if (it.next()) |entry| { + selected_key = entry.key_ptr.*; if (entry.value_ptr.schema) |schema_or_ref| { schema = try self.convertSchemaOrReference(schema_or_ref); } } } + const content_type: ?[]const u8 = if (selected_key) |k| + (if (k.len == 0) null else try self.allocator.dupe(u8, k)) + else + null; return Parameter{ .name = "body", .location = .body, @@ -606,9 +620,19 @@ pub const OpenApi31Converter = struct { .schema = schema, .type = null, .format = null, + .content_type = content_type, }; } + fn selectJsonSuffixKey31(content: std.StringHashMap(@import("../../models/v3.1/media.zig").MediaType)) ?[]const u8 { + var it = content.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + if (std.mem.endsWith(u8, key, "+json")) return key; + } + return null; + } + fn convertParameters(self: *OpenApi31Converter, parameters: []const ParameterOrReference31) ![]Parameter { var converted_params = try self.allocator.alloc(Parameter, parameters.len); for (parameters, 0..) |param_ref, i| { diff --git a/src/generators/converters/openapi32_converter.zig b/src/generators/converters/openapi32_converter.zig index 80cca2b..ffaad12 100644 --- a/src/generators/converters/openapi32_converter.zig +++ b/src/generators/converters/openapi32_converter.zig @@ -339,18 +339,32 @@ pub const OpenApi32Converter = struct { fn convertRequestBody(self: *OpenApi32Converter, requestBody: *const RequestBody32) !Parameter { var mut_request_body = requestBody.*; var schema: ?Schema = null; + var selected_key: ?[]const u8 = null; if (mut_request_body.content.get("application/json")) |media_type| { + selected_key = "application/json"; if (media_type.schema) |schema_or_ref| { schema = try self.convertSchemaOrReference(schema_or_ref); } + } else if (selectJsonSuffixKey32(mut_request_body.content)) |key| { + if (mut_request_body.content.get(key)) |media_type| { + selected_key = key; + if (media_type.schema) |schema_or_ref| { + schema = try self.convertSchemaOrReference(schema_or_ref); + } + } } else if (mut_request_body.content.count() > 0) { var it = mut_request_body.content.iterator(); if (it.next()) |entry| { + selected_key = entry.key_ptr.*; if (entry.value_ptr.schema) |schema_or_ref| { schema = try self.convertSchemaOrReference(schema_or_ref); } } } + const content_type: ?[]const u8 = if (selected_key) |k| + (if (k.len == 0) null else try self.allocator.dupe(u8, k)) + else + null; return Parameter{ .name = "body", .location = .body, @@ -359,9 +373,19 @@ pub const OpenApi32Converter = struct { .schema = schema, .type = null, .format = null, + .content_type = content_type, }; } + fn selectJsonSuffixKey32(content: std.StringHashMap(@import("../../models/v3.2/media.zig").MediaType)) ?[]const u8 { + var it = content.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + if (std.mem.endsWith(u8, key, "+json")) return key; + } + return null; + } + fn convertParameters(self: *OpenApi32Converter, parameters: []const ParameterOrReference32) ![]Parameter { var converted_params = try self.allocator.alloc(Parameter, parameters.len); for (parameters, 0..) |param_ref, i| { diff --git a/src/tests/binary_payload_tests.zig b/src/tests/binary_payload_tests.zig index 075939b..470c848 100644 --- a/src/tests/binary_payload_tests.zig +++ b/src/tests/binary_payload_tests.zig @@ -8,6 +8,8 @@ const UnifiedDocument = document.UnifiedDocument; const Operation = document.Operation; const models = @import("../models.zig"); const OpenApiConverter = @import("../generators/converters/openapi_converter.zig").OpenApiConverter; +const OpenApi31Converter = @import("../generators/converters/openapi31_converter.zig").OpenApi31Converter; +const OpenApi32Converter = @import("../generators/converters/openapi32_converter.zig").OpenApi32Converter; fn loadOpenApiDocument(allocator: std.mem.Allocator, file_path: []const u8) !models.OpenApiDocument { const file_contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, file_path, allocator, .unlimited); @@ -197,3 +199,72 @@ test "v3.0 converter :: vendor +json suffix selected over non-json" { const body = findBodyParameter(op) orelse return error.BodyNotFound; try testing.expectEqualStrings("application/vnd.acme+json", body.content_type.?); } + +test "v3.1 converter :: octet-stream body captures content_type" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + + const spec = + \\{ + \\ "openapi": "3.1.0", + \\ "info": { "title": "T", "version": "1" }, + \\ "paths": { + \\ "/upload": { + \\ "post": { + \\ "operationId": "doUpload", + \\ "requestBody": { + \\ "content": { + \\ "application/octet-stream": { "schema": { "type": "string", "format": "binary" } } + \\ } + \\ }, + \\ "responses": { "200": { "description": "ok" } } + \\ } + \\ } + \\ } + \\} + ; + var parsed = try models.OpenApi31Document.parseFromJson(allocator, spec); + defer parsed.deinit(allocator); + var converter = OpenApi31Converter.init(allocator); + var unified = try converter.convert(parsed); + defer unified.deinit(allocator); + + const op = findOperation(&unified, "/upload", .post) orelse return error.OperationNotFound; + const body = findBodyParameter(op) orelse return error.BodyNotFound; + try testing.expectEqualStrings("application/octet-stream", body.content_type.?); +} + +test "v3.2 converter :: JSON wins over XML" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + + const spec = + \\{ + \\ "openapi": "3.2.0", + \\ "info": { "title": "T", "version": "1" }, + \\ "paths": { + \\ "/x": { + \\ "post": { + \\ "operationId": "doX", + \\ "requestBody": { + \\ "content": { + \\ "application/xml": { "schema": { "type": "string" } }, + \\ "application/json": { "schema": { "type": "object" } } + \\ } + \\ }, + \\ "responses": { "200": { "description": "ok" } } + \\ } + \\ } + \\ } + \\} + ; + var parsed = try models.OpenApi32Document.parseFromJson(allocator, spec); + defer parsed.deinit(allocator); + var converter = OpenApi32Converter.init(allocator); + var unified = try converter.convert(parsed); + defer unified.deinit(allocator); + + const op = findOperation(&unified, "/x", .post) orelse return error.OperationNotFound; + const body = findBodyParameter(op) orelse return error.BodyNotFound; + try testing.expectEqualStrings("application/json", body.content_type.?); +} From 99463d8485e2b5e956642c863ec56a586e82a66d Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Wed, 20 May 2026 17:21:43 +0200 Subject: [PATCH 04/15] converter(v2.0): resolve consumes onto Parameter.content_type Threads operation-level consumes (with spec-level fallback) into convertParameter for body parameters. Applies the same JSON-preference rule used by the v3.x converters: application/json wins, then any *+json suffix, otherwise the first listed media type. Adds a minimal Swagger 2.0 fixture exercising both operation-level octet-stream and spec-level JSON inheritance, and pairs it with two converter tests. --- openapi/v2.0/binary-upload.json | 58 +++++++++++++++++++ .../converters/swagger_converter.zig | 38 ++++++++++-- src/tests/binary_payload_tests.zig | 39 +++++++++++++ 3 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 openapi/v2.0/binary-upload.json diff --git a/openapi/v2.0/binary-upload.json b/openapi/v2.0/binary-upload.json new file mode 100644 index 0000000..d461038 --- /dev/null +++ b/openapi/v2.0/binary-upload.json @@ -0,0 +1,58 @@ +{ + "swagger": "2.0", + "info": { + "title": "Binary Upload Sample", + "version": "1.0.0" + }, + "host": "example.com", + "basePath": "/v1", + "schemes": ["https"], + "consumes": ["application/json"], + "produces": ["application/json"], + "paths": { + "/upload": { + "post": { + "operationId": "uploadBinary", + "summary": "Upload a binary file", + "consumes": ["application/octet-stream"], + "parameters": [ + { + "name": "file", + "in": "body", + "required": true, + "schema": { + "type": "string", + "format": "binary" + } + } + ], + "responses": { + "200": { + "description": "Upload succeeded" + } + } + } + }, + "/echo": { + "post": { + "operationId": "echoJson", + "summary": "Echo a JSON body using spec-level consumes", + "parameters": [ + { + "name": "payload", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "ok" + } + } + } + } + } +} diff --git a/src/generators/converters/swagger_converter.zig b/src/generators/converters/swagger_converter.zig index 1d758c8..83134f1 100644 --- a/src/generators/converters/swagger_converter.zig +++ b/src/generators/converters/swagger_converter.zig @@ -32,6 +32,7 @@ const Paths2 = @import("../../models/v2.0/paths.zig").Paths; pub const SwaggerConverter = struct { allocator: std.mem.Allocator, + spec_consumes: ?[]const []const u8 = null, pub fn init(allocator: std.mem.Allocator) SwaggerConverter { return SwaggerConverter{ .allocator = allocator }; @@ -40,6 +41,8 @@ pub const SwaggerConverter = struct { pub fn convert(self: *SwaggerConverter, swagger: SwaggerDocument) !UnifiedDocument { const version = swagger.swagger; // Reference, don't duplicate const info = self.convertInfo(swagger.info); + if (swagger.consumes) |c| self.spec_consumes = c; + defer self.spec_consumes = null; const paths = try self.convertPaths(swagger.paths); const servers = try self.createServersFromHostAndBasePath(swagger.host, swagger.basePath, swagger.schemes); const security = if (swagger.security) |security_list| try self.convertSecurityRequirements(security_list) else null; @@ -156,7 +159,7 @@ pub const SwaggerConverter = struct { var param_iterator = parameters.iterator(); while (param_iterator.next()) |entry| { const key = try self.allocator.dupe(u8, entry.key_ptr.*); - const param = try self.convertParameter(entry.value_ptr.*); + const param = try self.convertParameter(entry.value_ptr.*, null); try converted_params.put(key, param); } return converted_params; @@ -247,7 +250,7 @@ pub const SwaggerConverter = struct { const options = if (pathItem.options) |op| try self.convertOperation(op) else null; const head = if (pathItem.head) |op| try self.convertOperation(op) else null; const patch = if (pathItem.patch) |op| try self.convertOperation(op) else null; - const parameters = if (pathItem.parameters) |params| try self.convertParameters(params) else null; + const parameters = if (pathItem.parameters) |params| try self.convertParameters(params, null) else null; return PathItem{ .get = get, .put = put, @@ -271,7 +274,8 @@ pub const SwaggerConverter = struct { const summary = operation.summary; // Reference, don't duplicate const description = operation.description; // Reference, don't duplicate const operationId = operation.operationId; // Reference, don't duplicate - const parameters = if (operation.parameters) |params| try self.convertParameters(params) else null; + const op_consumes: ?[]const []const u8 = if (operation.consumes) |c| c else self.spec_consumes; + const parameters = if (operation.parameters) |params| try self.convertParameters(params, op_consumes) else null; var responses = std.StringHashMap(Response).init(self.allocator); var resp_iterator = operation.responses.iterator(); while (resp_iterator.next()) |entry| { @@ -292,15 +296,15 @@ pub const SwaggerConverter = struct { }; } - fn convertParameters(self: *SwaggerConverter, parameters: []Parameter2) ![]Parameter { + fn convertParameters(self: *SwaggerConverter, parameters: []Parameter2, consumes: ?[]const []const u8) ![]Parameter { var converted_params = try self.allocator.alloc(Parameter, parameters.len); for (parameters, 0..) |param, i| { - converted_params[i] = try self.convertParameter(param); + converted_params[i] = try self.convertParameter(param, consumes); } return converted_params; } - fn convertParameter(self: *SwaggerConverter, parameter: Parameter2) !Parameter { + fn convertParameter(self: *SwaggerConverter, parameter: Parameter2, consumes: ?[]const []const u8) !Parameter { const name = parameter.name; // Reference, don't duplicate const location = self.convertParameterLocation(parameter.in); const description = parameter.description; // Reference, don't duplicate @@ -311,6 +315,16 @@ pub const SwaggerConverter = struct { } else null; const param_type = if (parameter.type) |type_val| self.convertParameterType(type_val) else null; const format = parameter.format; // Reference, don't duplicate + var content_type: ?[]const u8 = null; + if (location == .body) { + if (consumes) |list| { + if (selectConsumesMedia(list)) |selected| { + if (selected.len > 0) { + content_type = try self.allocator.dupe(u8, selected); + } + } + } + } return Parameter{ .name = name, .location = location, @@ -319,9 +333,21 @@ pub const SwaggerConverter = struct { .schema = schema, .type = param_type, .format = format, + .content_type = content_type, }; } + fn selectConsumesMedia(list: []const []const u8) ?[]const u8 { + if (list.len == 0) return null; + for (list) |m| { + if (std.mem.eql(u8, m, "application/json")) return m; + } + for (list) |m| { + if (std.mem.endsWith(u8, m, "+json")) return m; + } + return list[0]; + } + fn convertParameterLocation(self: *SwaggerConverter, location: ParameterLocation2) ParameterLocation { _ = self; return switch (location) { diff --git a/src/tests/binary_payload_tests.zig b/src/tests/binary_payload_tests.zig index 470c848..c39a239 100644 --- a/src/tests/binary_payload_tests.zig +++ b/src/tests/binary_payload_tests.zig @@ -7,6 +7,7 @@ const ParameterLocation = document.ParameterLocation; const UnifiedDocument = document.UnifiedDocument; const Operation = document.Operation; const models = @import("../models.zig"); +const SwaggerConverter = @import("../generators/converters/swagger_converter.zig").SwaggerConverter; const OpenApiConverter = @import("../generators/converters/openapi_converter.zig").OpenApiConverter; const OpenApi31Converter = @import("../generators/converters/openapi31_converter.zig").OpenApi31Converter; const OpenApi32Converter = @import("../generators/converters/openapi32_converter.zig").OpenApi32Converter; @@ -17,6 +18,12 @@ fn loadOpenApiDocument(allocator: std.mem.Allocator, file_path: []const u8) !mod return try models.OpenApiDocument.parseFromJson(allocator, file_contents); } +fn loadSwaggerDocument(allocator: std.mem.Allocator, file_path: []const u8) !models.SwaggerDocument { + const file_contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, file_path, allocator, .unlimited); + defer allocator.free(file_contents); + return try models.SwaggerDocument.parseFromJson(allocator, file_contents); +} + fn parseAndConvertV3(allocator: std.mem.Allocator, json: []const u8) !UnifiedDocument { var parsed = try models.OpenApiDocument.parseFromJson(allocator, json); defer parsed.deinit(allocator); @@ -268,3 +275,35 @@ test "v3.2 converter :: JSON wins over XML" { const body = findBodyParameter(op) orelse return error.BodyNotFound; try testing.expectEqualStrings("application/json", body.content_type.?); } + +test "v2.0 converter :: operation-level consumes octet-stream wins over spec-level json" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + + var parsed = try loadSwaggerDocument(allocator, "openapi/v2.0/binary-upload.json"); + defer parsed.deinit(allocator); + var converter = SwaggerConverter.init(allocator); + var unified = try converter.convert(parsed); + defer unified.deinit(allocator); + + const op = findOperation(&unified, "/upload", .post) orelse return error.OperationNotFound; + const body = findBodyParameter(op) orelse return error.BodyNotFound; + try testing.expect(body.content_type != null); + try testing.expectEqualStrings("application/octet-stream", body.content_type.?); +} + +test "v2.0 converter :: spec-level consumes inherits when operation omits consumes" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + + var parsed = try loadSwaggerDocument(allocator, "openapi/v2.0/binary-upload.json"); + defer parsed.deinit(allocator); + var converter = SwaggerConverter.init(allocator); + var unified = try converter.convert(parsed); + defer unified.deinit(allocator); + + const op = findOperation(&unified, "/echo", .post) orelse return error.OperationNotFound; + const body = findBodyParameter(op) orelse return error.BodyNotFound; + try testing.expect(body.content_type != null); + try testing.expectEqualStrings("application/json", body.content_type.?); +} From 5c9f76ab019810f83bb50746e23e84e177d50b48 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Wed, 20 May 2026 17:23:54 +0200 Subject: [PATCH 05/15] api_generator: introduce private BodyKind classifier Adds a private BodyKind enum (none/json/binary/text/form) and a classifyBody helper that maps content-type strings to body kinds. Pure refactor: no call sites yet, generated output is byte-identical. Unit-tests cover JSON, +json suffix, octet-stream, image/audio/video, */*, text/*, multipart/*, and x-www-form-urlencoded. --- src/generators/unified/api_generator.zig | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/generators/unified/api_generator.zig b/src/generators/unified/api_generator.zig index 80e6d8d..685b6d0 100644 --- a/src/generators/unified/api_generator.zig +++ b/src/generators/unified/api_generator.zig @@ -9,6 +9,35 @@ fn isIdentStart(c: u8) bool { return std.ascii.isAlphabetic(c) or c == '_'; } +const BodyKind = enum { none, json, binary, text, form }; + +fn startsWithIgnoreCase(haystack: []const u8, prefix: []const u8) bool { + if (haystack.len < prefix.len) return false; + return std.ascii.eqlIgnoreCase(haystack[0..prefix.len], prefix); +} + +fn endsWithIgnoreCase(haystack: []const u8, suffix: []const u8) bool { + if (haystack.len < suffix.len) return false; + return std.ascii.eqlIgnoreCase(haystack[haystack.len - suffix.len ..], suffix); +} + +fn classifyBody(content_type: ?[]const u8) BodyKind { + const ct = content_type orelse return .json; + if (ct.len == 0) return .json; + if (std.ascii.eqlIgnoreCase(ct, "application/json")) return .json; + if (endsWithIgnoreCase(ct, "+json")) return .json; + if (std.ascii.eqlIgnoreCase(ct, "application/x-www-form-urlencoded")) return .form; + if (startsWithIgnoreCase(ct, "multipart/")) return .form; + if (startsWithIgnoreCase(ct, "text/")) return .text; + if (std.ascii.eqlIgnoreCase(ct, "application/octet-stream")) return .binary; + if (startsWithIgnoreCase(ct, "image/")) return .binary; + if (startsWithIgnoreCase(ct, "audio/")) return .binary; + if (startsWithIgnoreCase(ct, "video/")) return .binary; + if (std.ascii.eqlIgnoreCase(ct, "*/*")) return .binary; + if (startsWithIgnoreCase(ct, "application/")) return .binary; + return .binary; +} + fn isIdentContinue(c: u8) bool { return std.ascii.isAlphanumeric(c) or c == '_'; } @@ -1565,3 +1594,22 @@ pub const UnifiedApiGenerator = struct { }); } }; + +test "BodyKind :: classifyBody routes media types correctly" { + const t = std.testing; + try t.expectEqual(BodyKind.json, classifyBody(null)); + try t.expectEqual(BodyKind.json, classifyBody("")); + try t.expectEqual(BodyKind.json, classifyBody("application/json")); + try t.expectEqual(BodyKind.json, classifyBody("application/vnd.api+json")); + try t.expectEqual(BodyKind.binary, classifyBody("application/octet-stream")); + try t.expectEqual(BodyKind.binary, classifyBody("image/png")); + try t.expectEqual(BodyKind.binary, classifyBody("audio/mpeg")); + try t.expectEqual(BodyKind.binary, classifyBody("video/mp4")); + try t.expectEqual(BodyKind.binary, classifyBody("*/*")); + try t.expectEqual(BodyKind.binary, classifyBody("application/xml")); + try t.expectEqual(BodyKind.binary, classifyBody("application/pdf")); + try t.expectEqual(BodyKind.text, classifyBody("text/plain")); + try t.expectEqual(BodyKind.text, classifyBody("text/csv")); + try t.expectEqual(BodyKind.form, classifyBody("application/x-www-form-urlencoded")); + try t.expectEqual(BodyKind.form, classifyBody("multipart/form-data")); +} From ed0ab3ae7e119b73d463826bb765c9629f331a42 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Wed, 20 May 2026 17:26:01 +0200 Subject: [PATCH 06/15] api_generator: parameterize appendClientHeaders Content-Type Switches the runtime appendClientHeaders helper from a bool flag plus hard-coded application/json to an optional content_type parameter. Updates the three emit sites (requestRaw, SSE, direct path) to pass "application/json" or null, preserving runtime behaviour for JSON operations. Generated client snapshots refreshed accordingly. --- generated/generated_v2.zig | 56 +++++++++++++----------- generated/generated_v2_yaml.zig | 11 ++--- generated/generated_v3.zig | 46 ++++++++++--------- generated/generated_v31.zig | 17 ++++--- generated/generated_v31_yaml.zig | 11 ++--- generated/generated_v32.zig | 26 ++++++----- generated/generated_v3_yaml.zig | 11 ++--- src/generators/unified/api_generator.zig | 13 +++--- 8 files changed, 106 insertions(+), 85 deletions(-) diff --git a/generated/generated_v2.zig b/generated/generated_v2.zig index fb49820..c9f3924 100644 --- a/generated/generated_v2.zig +++ b/generated/generated_v2.zig @@ -44,15 +44,17 @@ pub const Order = struct { }; pub const ApiResponse = struct { - type: ?[]const u8 = null, + @"type": ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; + /////////////////////////////////////////// // Generated Zig API client from OpenAPI /////////////////////////////////////////// + pub fn Owned(comptime T: type) type { return struct { allocator: std.mem.Allocator, @@ -172,7 +174,8 @@ pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, pay const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, payload != null, "application/json"); + const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); const uri = try std.Uri.parse(url); @@ -354,7 +357,7 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, true, "text/event-stream"); + const auth_header = try appendClientHeaders(allocator, &headers, client, "application/json", "text/event-stream"); defer if (auth_header) |value| allocator.free(value); const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ client.base_url, path }); @@ -385,9 +388,9 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: }; } -fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, include_content_type: bool, accept: []const u8) !?[]u8 { - if (include_content_type) { - try headers.append(allocator, .{ .name = "Content-Type", .value = "application/json" }); +fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, content_type: ?[]const u8, accept: []const u8) !?[]u8 { + if (content_type) |ct| { + try headers.append(allocator, .{ .name = "Content-Type", .value = ct }); } try headers.append(allocator, .{ .name = "Accept", .value = accept }); @@ -413,7 +416,7 @@ fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std // Place an order for a pet // // Description: -// +// // pub fn placeOrder(client: *Client, requestBody: Order) !Owned(Order) { var result = try placeOrderResult(client, requestBody); @@ -453,7 +456,7 @@ pub fn placeOrderResult(client: *Client, requestBody: Order) !ApiResult(Order) { // uploads an image // // Description: -// +// // pub fn uploadFile(client: *Client, petId: i64, additionalMetadata: []const u8, file: []const u8) !Owned(ApiResponse) { var result = try uploadFileResult(client, petId, additionalMetadata, file); @@ -476,7 +479,7 @@ pub fn uploadFileRaw(client: *Client, petId: i64, additionalMetadata: []const u8 _ = file; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/pet/{d}/uploadImage", .{ client.base_url, petId }); + try uri_buf.writer.print("{s}/pet/{d}/uploadImage", .{client.base_url, petId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.POST, uri_buf.written(), payload); @@ -512,7 +515,7 @@ pub fn getPetByIdRaw(client: *Client, petId: i64) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/pet/{d}", .{ client.base_url, petId }); + try uri_buf.writer.print("{s}/pet/{d}", .{client.base_url, petId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); @@ -527,7 +530,7 @@ pub fn getPetByIdResult(client: *Client, petId: i64) !ApiResult(Pet) { // Updates a pet in the store with form data // // Description: -// +// // pub fn updatePetWithForm(client: *Client, petId: i64, name: []const u8, status: []const u8) !void { var raw = try updatePetWithFormRaw(client, petId, name, status); @@ -541,7 +544,7 @@ pub fn updatePetWithFormRaw(client: *Client, petId: i64, name: []const u8, statu _ = status; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/pet/{d}", .{ client.base_url, petId }); + try uri_buf.writer.print("{s}/pet/{d}", .{client.base_url, petId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.POST, uri_buf.written(), payload); @@ -552,7 +555,7 @@ pub fn updatePetWithFormRaw(client: *Client, petId: i64, name: []const u8, statu // Deletes a pet // // Description: -// +// // pub fn deletePet(client: *Client, api_key: []const u8, petId: i64) !void { var raw = try deletePetRaw(client, api_key, petId); @@ -565,7 +568,7 @@ pub fn deletePetRaw(client: *Client, api_key: []const u8, petId: i64) !RawRespon _ = api_key; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/pet/{d}", .{ client.base_url, petId }); + try uri_buf.writer.print("{s}/pet/{d}", .{client.base_url, petId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.DELETE, uri_buf.written(), payload); @@ -614,7 +617,7 @@ pub fn findPetsByTagsResult(client: *Client, tags: []const std.json.Value) !ApiR // Logs user into the system // // Description: -// +// // pub fn loginUser(client: *Client, username: []const u8, password: []const u8) !Owned([]const u8) { var result = try loginUserResult(client, username, password); @@ -653,7 +656,7 @@ pub fn loginUserResult(client: *Client, username: []const u8, password: []const // Creates list of users with given input array // // Description: -// +// // pub fn createUsersWithArrayInput(client: *Client, requestBody: []const std.json.Value) !void { var raw = try createUsersWithArrayInputRaw(client, requestBody); @@ -754,7 +757,7 @@ pub fn getInventoryResult(client: *Client) !ApiResult(std.json.Value) { // Get user by user name // // Description: -// +// // pub fn getUserByName(client: *Client, username: []const u8) !Owned(User) { var result = try getUserByNameResult(client, username); @@ -775,7 +778,7 @@ pub fn getUserByNameRaw(client: *Client, username: []const u8) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/user/{s}", .{ client.base_url, username }); + try uri_buf.writer.print("{s}/user/{s}", .{client.base_url, username}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); @@ -802,7 +805,7 @@ pub fn updateUserRaw(client: *Client, username: []const u8, requestBody: User) ! const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/user/{s}", .{ client.base_url, username }); + try uri_buf.writer.print("{s}/user/{s}", .{client.base_url, username}); var str: std.Io.Writer.Allocating = .init(allocator); defer str.deinit(); @@ -829,7 +832,7 @@ pub fn deleteUserRaw(client: *Client, username: []const u8) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/user/{s}", .{ client.base_url, username }); + try uri_buf.writer.print("{s}/user/{s}", .{client.base_url, username}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.DELETE, uri_buf.written(), payload); @@ -867,7 +870,7 @@ pub fn createUserRaw(client: *Client, requestBody: User) !RawResponse { // Creates list of users with given input array // // Description: -// +// // pub fn createUsersWithListInput(client: *Client, requestBody: []const std.json.Value) !void { var raw = try createUsersWithListInputRaw(client, requestBody); @@ -894,7 +897,7 @@ pub fn createUsersWithListInputRaw(client: *Client, requestBody: []const std.jso // Add a new pet to the store // // Description: -// +// // pub fn addPet(client: *Client, requestBody: Pet) !void { var raw = try addPetRaw(client, requestBody); @@ -921,7 +924,7 @@ pub fn addPetRaw(client: *Client, requestBody: Pet) !RawResponse { // Update an existing pet // // Description: -// +// // pub fn updatePet(client: *Client, requestBody: Pet) !void { var raw = try updatePetRaw(client, requestBody); @@ -969,7 +972,7 @@ pub fn getOrderByIdRaw(client: *Client, orderId: i64) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/store/order/{d}", .{ client.base_url, orderId }); + try uri_buf.writer.print("{s}/store/order/{d}", .{client.base_url, orderId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); @@ -996,7 +999,7 @@ pub fn deleteOrderRaw(client: *Client, orderId: i64) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/store/order/{d}", .{ client.base_url, orderId }); + try uri_buf.writer.print("{s}/store/order/{d}", .{client.base_url, orderId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.DELETE, uri_buf.written(), payload); @@ -1007,7 +1010,7 @@ pub fn deleteOrderRaw(client: *Client, orderId: i64) !RawResponse { // Logs out current logged in user session // // Description: -// +// // pub fn logoutUser(client: *Client) !void { var raw = try logoutUserRaw(client); @@ -1142,3 +1145,4 @@ pub const resources = struct { pub const pet = resources.pet; pub const store = resources.store; pub const user = resources.user; + diff --git a/generated/generated_v2_yaml.zig b/generated/generated_v2_yaml.zig index 23663ef..e6a0698 100644 --- a/generated/generated_v2_yaml.zig +++ b/generated/generated_v2_yaml.zig @@ -174,7 +174,8 @@ pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, pay const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, payload != null, "application/json"); + const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); const uri = try std.Uri.parse(url); @@ -356,7 +357,7 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, true, "text/event-stream"); + const auth_header = try appendClientHeaders(allocator, &headers, client, "application/json", "text/event-stream"); defer if (auth_header) |value| allocator.free(value); const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ client.base_url, path }); @@ -387,9 +388,9 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: }; } -fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, include_content_type: bool, accept: []const u8) !?[]u8 { - if (include_content_type) { - try headers.append(allocator, .{ .name = "Content-Type", .value = "application/json" }); +fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, content_type: ?[]const u8, accept: []const u8) !?[]u8 { + if (content_type) |ct| { + try headers.append(allocator, .{ .name = "Content-Type", .value = ct }); } try headers.append(allocator, .{ .name = "Accept", .value = accept }); diff --git a/generated/generated_v3.zig b/generated/generated_v3.zig index f07807b..74c8fe5 100644 --- a/generated/generated_v3.zig +++ b/generated/generated_v3.zig @@ -57,15 +57,17 @@ pub const User = struct { }; pub const ApiResponse = struct { - type: ?[]const u8 = null, + @"type": ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; + /////////////////////////////////////////// // Generated Zig API client from OpenAPI /////////////////////////////////////////// + pub fn Owned(comptime T: type) type { return struct { allocator: std.mem.Allocator, @@ -185,7 +187,8 @@ pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, pay const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, payload != null, "application/json"); + const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); const uri = try std.Uri.parse(url); @@ -367,7 +370,7 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, true, "text/event-stream"); + const auth_header = try appendClientHeaders(allocator, &headers, client, "application/json", "text/event-stream"); defer if (auth_header) |value| allocator.free(value); const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ client.base_url, path }); @@ -398,9 +401,9 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: }; } -fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, include_content_type: bool, accept: []const u8) !?[]u8 { - if (include_content_type) { - try headers.append(allocator, .{ .name = "Content-Type", .value = "application/json" }); +fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, content_type: ?[]const u8, accept: []const u8) !?[]u8 { + if (content_type) |ct| { + try headers.append(allocator, .{ .name = "Content-Type", .value = ct }); } try headers.append(allocator, .{ .name = "Accept", .value = accept }); @@ -466,7 +469,7 @@ pub fn placeOrderResult(client: *Client, requestBody: Order) !ApiResult(Order) { // uploads an image // // Description: -// +// // pub fn uploadFile(client: *Client, petId: i64, additionalMetadata: ?[]const u8, requestBody: []const u8) !Owned(ApiResponse) { var result = try uploadFileResult(client, petId, additionalMetadata, requestBody); @@ -487,7 +490,7 @@ pub fn uploadFileRaw(client: *Client, petId: i64, additionalMetadata: ?[]const u const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/pet/{d}/uploadImage", .{ client.base_url, petId }); + try uri_buf.writer.print("{s}/pet/{d}/uploadImage", .{client.base_url, petId}); var first_query = true; if (additionalMetadata) |value| { try appendQueryParam(&uri_buf.writer, &first_query, "additionalMetadata", value); @@ -531,7 +534,7 @@ pub fn getPetByIdRaw(client: *Client, petId: i64) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/pet/{d}", .{ client.base_url, petId }); + try uri_buf.writer.print("{s}/pet/{d}", .{client.base_url, petId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); @@ -546,7 +549,7 @@ pub fn getPetByIdResult(client: *Client, petId: i64) !ApiResult(Pet) { // Updates a pet in the store with form data // // Description: -// +// // pub fn updatePetWithForm(client: *Client, petId: i64, name: ?[]const u8, status: ?[]const u8) !void { var raw = try updatePetWithFormRaw(client, petId, name, status); @@ -558,7 +561,7 @@ pub fn updatePetWithFormRaw(client: *Client, petId: i64, name: ?[]const u8, stat const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/pet/{d}", .{ client.base_url, petId }); + try uri_buf.writer.print("{s}/pet/{d}", .{client.base_url, petId}); var first_query = true; if (name) |value| { try appendQueryParam(&uri_buf.writer, &first_query, "name", value); @@ -576,7 +579,7 @@ pub fn updatePetWithFormRaw(client: *Client, petId: i64, name: ?[]const u8, stat // Deletes a pet // // Description: -// +// // pub fn deletePet(client: *Client, api_key: []const u8, petId: i64) !void { var raw = try deletePetRaw(client, api_key, petId); @@ -589,7 +592,7 @@ pub fn deletePetRaw(client: *Client, api_key: []const u8, petId: i64) !RawRespon _ = api_key; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/pet/{d}", .{ client.base_url, petId }); + try uri_buf.writer.print("{s}/pet/{d}", .{client.base_url, petId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.DELETE, uri_buf.written(), payload); @@ -640,7 +643,7 @@ pub fn findPetsByTagsResult(client: *Client, tags: ?[]const u8) !ApiResult([]con // Logs user into the system // // Description: -// +// // pub fn loginUser(client: *Client, username: ?[]const u8, password: ?[]const u8) !Owned([]const u8) { var result = try loginUserResult(client, username, password); @@ -759,7 +762,7 @@ pub fn getInventoryResult(client: *Client) !ApiResult(std.json.Value) { // Get user by user name // // Description: -// +// // pub fn getUserByName(client: *Client, username: []const u8) !Owned(User) { var result = try getUserByNameResult(client, username); @@ -780,7 +783,7 @@ pub fn getUserByNameRaw(client: *Client, username: []const u8) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/user/{s}", .{ client.base_url, username }); + try uri_buf.writer.print("{s}/user/{s}", .{client.base_url, username}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); @@ -807,7 +810,7 @@ pub fn updateUserRaw(client: *Client, username: []const u8, requestBody: User) ! const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/user/{s}", .{ client.base_url, username }); + try uri_buf.writer.print("{s}/user/{s}", .{client.base_url, username}); var str: std.Io.Writer.Allocating = .init(allocator); defer str.deinit(); @@ -834,7 +837,7 @@ pub fn deleteUserRaw(client: *Client, username: []const u8) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/user/{s}", .{ client.base_url, username }); + try uri_buf.writer.print("{s}/user/{s}", .{client.base_url, username}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.DELETE, uri_buf.written(), payload); @@ -1013,7 +1016,7 @@ pub fn getOrderByIdRaw(client: *Client, orderId: i64) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/store/order/{d}", .{ client.base_url, orderId }); + try uri_buf.writer.print("{s}/store/order/{d}", .{client.base_url, orderId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); @@ -1040,7 +1043,7 @@ pub fn deleteOrderRaw(client: *Client, orderId: i64) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/store/order/{d}", .{ client.base_url, orderId }); + try uri_buf.writer.print("{s}/store/order/{d}", .{client.base_url, orderId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.DELETE, uri_buf.written(), payload); @@ -1051,7 +1054,7 @@ pub fn deleteOrderRaw(client: *Client, orderId: i64) !RawResponse { // Logs out current logged in user session // // Description: -// +// // pub fn logoutUser(client: *Client) !void { var raw = try logoutUserRaw(client); @@ -1190,3 +1193,4 @@ pub const resources = struct { pub const pet = resources.pet; pub const store = resources.store; pub const user = resources.user; + diff --git a/generated/generated_v31.zig b/generated/generated_v31.zig index e21f58b..2343a5b 100644 --- a/generated/generated_v31.zig +++ b/generated/generated_v31.zig @@ -10,10 +10,12 @@ pub const Pet = struct { name: []const u8, }; + /////////////////////////////////////////// // Generated Zig API client from OpenAPI /////////////////////////////////////////// + pub fn Owned(comptime T: type) type { return struct { allocator: std.mem.Allocator, @@ -133,7 +135,8 @@ pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, pay const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, payload != null, "application/json"); + const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); const uri = try std.Uri.parse(url); @@ -315,7 +318,7 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, true, "text/event-stream"); + const auth_header = try appendClientHeaders(allocator, &headers, client, "application/json", "text/event-stream"); defer if (auth_header) |value| allocator.free(value); const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ client.base_url, path }); @@ -346,9 +349,9 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: }; } -fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, include_content_type: bool, accept: []const u8) !?[]u8 { - if (include_content_type) { - try headers.append(allocator, .{ .name = "Content-Type", .value = "application/json" }); +fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, content_type: ?[]const u8, accept: []const u8) !?[]u8 { + if (content_type) |ct| { + try headers.append(allocator, .{ .name = "Content-Type", .value = ct }); } try headers.append(allocator, .{ .name = "Accept", .value = accept }); @@ -369,4 +372,6 @@ fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std return auth_header; } -pub const resources = struct {}; +pub const resources = struct { +}; + diff --git a/generated/generated_v31_yaml.zig b/generated/generated_v31_yaml.zig index 03815c9..2343a5b 100644 --- a/generated/generated_v31_yaml.zig +++ b/generated/generated_v31_yaml.zig @@ -135,7 +135,8 @@ pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, pay const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, payload != null, "application/json"); + const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); const uri = try std.Uri.parse(url); @@ -317,7 +318,7 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, true, "text/event-stream"); + const auth_header = try appendClientHeaders(allocator, &headers, client, "application/json", "text/event-stream"); defer if (auth_header) |value| allocator.free(value); const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ client.base_url, path }); @@ -348,9 +349,9 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: }; } -fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, include_content_type: bool, accept: []const u8) !?[]u8 { - if (include_content_type) { - try headers.append(allocator, .{ .name = "Content-Type", .value = "application/json" }); +fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, content_type: ?[]const u8, accept: []const u8) !?[]u8 { + if (content_type) |ct| { + try headers.append(allocator, .{ .name = "Content-Type", .value = ct }); } try headers.append(allocator, .{ .name = "Accept", .value = accept }); diff --git a/generated/generated_v32.zig b/generated/generated_v32.zig index c414df7..3e72b8d 100644 --- a/generated/generated_v32.zig +++ b/generated/generated_v32.zig @@ -44,15 +44,17 @@ pub const Order = struct { }; pub const ApiResponse = struct { - type: ?[]const u8 = null, + @"type": ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; + /////////////////////////////////////////// // Generated Zig API client from OpenAPI /////////////////////////////////////////// + pub fn Owned(comptime T: type) type { return struct { allocator: std.mem.Allocator, @@ -172,7 +174,8 @@ pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, pay const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, payload != null, "application/json"); + const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); const uri = try std.Uri.parse(url); @@ -354,7 +357,7 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, true, "text/event-stream"); + const auth_header = try appendClientHeaders(allocator, &headers, client, "application/json", "text/event-stream"); defer if (auth_header) |value| allocator.free(value); const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ client.base_url, path }); @@ -385,9 +388,9 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: }; } -fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, include_content_type: bool, accept: []const u8) !?[]u8 { - if (include_content_type) { - try headers.append(allocator, .{ .name = "Content-Type", .value = "application/json" }); +fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, content_type: ?[]const u8, accept: []const u8) !?[]u8 { + if (content_type) |ct| { + try headers.append(allocator, .{ .name = "Content-Type", .value = ct }); } try headers.append(allocator, .{ .name = "Accept", .value = accept }); @@ -449,7 +452,7 @@ pub fn getInventoryResult(client: *Client) !ApiResult(std.json.Value) { // Get user by user name // // Description: -// +// // pub fn getUserByName(client: *Client, username: []const u8) !Owned(User) { var result = try getUserByNameResult(client, username); @@ -470,7 +473,7 @@ pub fn getUserByNameRaw(client: *Client, username: []const u8) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/user/{s}", .{ client.base_url, username }); + try uri_buf.writer.print("{s}/user/{s}", .{client.base_url, username}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); @@ -586,7 +589,7 @@ pub fn getPetByIdRaw(client: *Client, petId: i64) !RawResponse { const allocator = client.allocator; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/pet/{d}", .{ client.base_url, petId }); + try uri_buf.writer.print("{s}/pet/{d}", .{client.base_url, petId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.GET, uri_buf.written(), payload); @@ -601,7 +604,7 @@ pub fn getPetByIdResult(client: *Client, petId: i64) !ApiResult(Pet) { // Deletes a pet // // Description: -// +// // pub fn deletePet(client: *Client, api_key: []const u8, petId: i64) !void { var raw = try deletePetRaw(client, api_key, petId); @@ -614,7 +617,7 @@ pub fn deletePetRaw(client: *Client, api_key: []const u8, petId: i64) !RawRespon _ = api_key; var uri_buf: std.Io.Writer.Allocating = .init(allocator); defer uri_buf.deinit(); - try uri_buf.writer.print("{s}/pet/{d}", .{ client.base_url, petId }); + try uri_buf.writer.print("{s}/pet/{d}", .{client.base_url, petId}); const payload: ?[]const u8 = null; return requestRaw(client, std.http.Method.DELETE, uri_buf.written(), payload); @@ -809,3 +812,4 @@ pub const resources = struct { pub const pet = resources.pet; pub const store = resources.store; pub const user = resources.user; + diff --git a/generated/generated_v3_yaml.zig b/generated/generated_v3_yaml.zig index 1a25a3d..74c8fe5 100644 --- a/generated/generated_v3_yaml.zig +++ b/generated/generated_v3_yaml.zig @@ -187,7 +187,8 @@ pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, pay const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, payload != null, "application/json"); + const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); const uri = try std.Uri.parse(url); @@ -369,7 +370,7 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const auth_header = try appendClientHeaders(allocator, &headers, client, true, "text/event-stream"); + const auth_header = try appendClientHeaders(allocator, &headers, client, "application/json", "text/event-stream"); defer if (auth_header) |value| allocator.free(value); const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ client.base_url, path }); @@ -400,9 +401,9 @@ fn streamJson(client: *Client, path: []const u8, requestBody: anytype, callback: }; } -fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, include_content_type: bool, accept: []const u8) !?[]u8 { - if (include_content_type) { - try headers.append(allocator, .{ .name = "Content-Type", .value = "application/json" }); +fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, content_type: ?[]const u8, accept: []const u8) !?[]u8 { + if (content_type) |ct| { + try headers.append(allocator, .{ .name = "Content-Type", .value = ct }); } try headers.append(allocator, .{ .name = "Accept", .value = accept }); diff --git a/src/generators/unified/api_generator.zig b/src/generators/unified/api_generator.zig index 685b6d0..791db48 100644 --- a/src/generators/unified/api_generator.zig +++ b/src/generators/unified/api_generator.zig @@ -320,7 +320,8 @@ pub const UnifiedApiGenerator = struct { \\ const allocator = client.allocator; \\ var headers = std.ArrayList(std.http.Header).empty; \\ defer headers.deinit(allocator); - \\ const auth_header = try appendClientHeaders(allocator, &headers, client, payload != null, "application/json"); + \\ const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + \\ const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); \\ defer if (auth_header) |value| allocator.free(value); \\ \\ const uri = try std.Uri.parse(url); @@ -502,7 +503,7 @@ pub const UnifiedApiGenerator = struct { \\ \\ var headers = std.ArrayList(std.http.Header).empty; \\ defer headers.deinit(allocator); - \\ const auth_header = try appendClientHeaders(allocator, &headers, client, true, "text/event-stream"); + \\ const auth_header = try appendClientHeaders(allocator, &headers, client, "application/json", "text/event-stream"); \\ defer if (auth_header) |value| allocator.free(value); \\ \\ const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ client.base_url, path }); @@ -533,9 +534,9 @@ pub const UnifiedApiGenerator = struct { \\ }; \\} \\ - \\fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, include_content_type: bool, accept: []const u8) !?[]u8 { - \\ if (include_content_type) { - \\ try headers.append(allocator, .{ .name = "Content-Type", .value = "application/json" }); + \\fn appendClientHeaders(allocator: std.mem.Allocator, headers: *std.ArrayList(std.http.Header), client: *Client, content_type: ?[]const u8, accept: []const u8) !?[]u8 { + \\ if (content_type) |ct| { + \\ try headers.append(allocator, .{ .name = "Content-Type", .value = ct }); \\ } \\ try headers.append(allocator, .{ .name = "Accept", .value = accept }); \\ @@ -1376,7 +1377,7 @@ pub const UnifiedApiGenerator = struct { try self.buffer.appendSlice(self.allocator, " var headers = std.ArrayList(std.http.Header).empty;\n"); try self.buffer.appendSlice(self.allocator, " defer headers.deinit(allocator);\n"); try self.buffer.appendSlice(self.allocator, " const auth_header = try appendClientHeaders(allocator, &headers, client, "); - try self.buffer.appendSlice(self.allocator, if (has_body_param) "true" else "false"); + try self.buffer.appendSlice(self.allocator, if (has_body_param) "\"application/json\"" else "null"); try self.buffer.appendSlice(self.allocator, ", \"application/json\");\n"); try self.buffer.appendSlice(self.allocator, " defer if (auth_header) |value| allocator.free(value);\n\n"); From c7e7403a02f116869b7996d6c2f2ecc8e81a27d1 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Wed, 20 May 2026 17:30:27 +0200 Subject: [PATCH 07/15] api_generator: emit raw bytes for binary and text request bodies Resolves #53. Operations whose request body content type classifies as binary (octet-stream, image/*, audio/*, video/*, */*, application/*) or text (text/*) now generate `requestBody: []const u8` parameters and pass the bytes straight to `http.fetch` with the captured Content-Type header. Multipart/x-www-form-urlencoded bodies still fall back to JSON encoding with a TODO marker pending follow-up work. JSON-bodied operations are unchanged. Generated client snapshots refreshed; uploadFile in generated_v3.zig now takes `[]const u8`, emits `application/octet-stream`, and never JSON-stringifies the payload. --- generated/generated_v3.zig | 27 ++++- generated/generated_v3_yaml.zig | 27 ++++- src/generators/unified/api_generator.zig | 132 ++++++++++++++++++++--- src/tests/binary_payload_tests.zig | 28 +++++ 4 files changed, 187 insertions(+), 27 deletions(-) diff --git a/generated/generated_v3.zig b/generated/generated_v3.zig index 74c8fe5..3109c50 100644 --- a/generated/generated_v3.zig +++ b/generated/generated_v3.zig @@ -495,13 +495,30 @@ pub fn uploadFileRaw(client: *Client, petId: i64, additionalMetadata: ?[]const u if (additionalMetadata) |value| { try appendQueryParam(&uri_buf.writer, &first_query, "additionalMetadata", value); } + const payload: ?[]const u8 = requestBody; - var str: std.Io.Writer.Allocating = .init(allocator); - defer str.deinit(); - try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer); - const payload: ?[]const u8 = str.written(); + var headers = std.ArrayList(std.http.Header).empty; + defer headers.deinit(allocator); + const auth_header = try appendClientHeaders(allocator, &headers, client, "application/octet-stream", "application/json"); + defer if (auth_header) |value| allocator.free(value); - return requestRaw(client, std.http.Method.POST, uri_buf.written(), payload); + const uri = try std.Uri.parse(uri_buf.written()); + var response_body: std.Io.Writer.Allocating = .init(allocator); + defer response_body.deinit(); + + const result = try client.http.fetch(.{ + .location = .{ .uri = uri }, + .method = std.http.Method.POST, + .extra_headers = headers.items, + .payload = payload, + .response_writer = &response_body.writer, + }); + + return .{ + .allocator = allocator, + .status = result.status, + .body = try response_body.toOwnedSlice(), + }; } pub fn uploadFileResult(client: *Client, petId: i64, additionalMetadata: ?[]const u8, requestBody: []const u8) !ApiResult(ApiResponse) { diff --git a/generated/generated_v3_yaml.zig b/generated/generated_v3_yaml.zig index 74c8fe5..3109c50 100644 --- a/generated/generated_v3_yaml.zig +++ b/generated/generated_v3_yaml.zig @@ -495,13 +495,30 @@ pub fn uploadFileRaw(client: *Client, petId: i64, additionalMetadata: ?[]const u if (additionalMetadata) |value| { try appendQueryParam(&uri_buf.writer, &first_query, "additionalMetadata", value); } + const payload: ?[]const u8 = requestBody; - var str: std.Io.Writer.Allocating = .init(allocator); - defer str.deinit(); - try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer); - const payload: ?[]const u8 = str.written(); + var headers = std.ArrayList(std.http.Header).empty; + defer headers.deinit(allocator); + const auth_header = try appendClientHeaders(allocator, &headers, client, "application/octet-stream", "application/json"); + defer if (auth_header) |value| allocator.free(value); - return requestRaw(client, std.http.Method.POST, uri_buf.written(), payload); + const uri = try std.Uri.parse(uri_buf.written()); + var response_body: std.Io.Writer.Allocating = .init(allocator); + defer response_body.deinit(); + + const result = try client.http.fetch(.{ + .location = .{ .uri = uri }, + .method = std.http.Method.POST, + .extra_headers = headers.items, + .payload = payload, + .response_writer = &response_body.writer, + }); + + return .{ + .allocator = allocator, + .status = result.status, + .body = try response_body.toOwnedSlice(), + }; } pub fn uploadFileResult(client: *Client, petId: i64, additionalMetadata: ?[]const u8, requestBody: []const u8) !ApiResult(ApiResponse) { diff --git a/src/generators/unified/api_generator.zig b/src/generators/unified/api_generator.zig index 791db48..e60ead7 100644 --- a/src/generators/unified/api_generator.zig +++ b/src/generators/unified/api_generator.zig @@ -4,6 +4,7 @@ const UnifiedDocument = @import("../../models/common/document.zig").UnifiedDocum const Operation = @import("../../models/common/document.zig").Operation; const Schema = @import("../../models/common/document.zig").Schema; const SchemaType = @import("../../models/common/document.zig").SchemaType; +const Parameter = @import("../../models/common/document.zig").Parameter; fn isIdentStart(c: u8) bool { return std.ascii.isAlphabetic(c) or c == '_'; @@ -38,6 +39,20 @@ fn classifyBody(content_type: ?[]const u8) BodyKind { return .binary; } +fn findBodyParam(operation: Operation) ?Parameter { + if (operation.parameters) |params| { + for (params) |p| { + if (p.location == .body) return p; + } + } + return null; +} + +fn bodyKindFor(operation: Operation) BodyKind { + const param = findBodyParam(operation) orelse return .none; + return classifyBody(param.content_type); +} + fn isIdentContinue(c: u8) bool { return std.ascii.isAlphanumeric(c) or c == '_'; } @@ -629,6 +644,8 @@ pub const UnifiedApiGenerator = struct { const raw_name = try std.fmt.allocPrint(self.allocator, "{s}Raw", .{operation_id}); defer self.allocator.free(raw_name); + const kind = bodyKindFor(operation); + try self.buffer.appendSlice(self.allocator, "pub fn "); try self.appendIdentifier(raw_name); try self.buffer.appendSlice(self.allocator, "(client: *Client"); @@ -638,18 +655,67 @@ pub const UnifiedApiGenerator = struct { try self.appendUnusedParameters(operation); try self.appendUrlConstruction(path, operation); - if (self.hasBodyParameter(operation)) { - try self.buffer.appendSlice(self.allocator, "\n var str: std.Io.Writer.Allocating = .init(allocator);\n"); - try self.buffer.appendSlice(self.allocator, " defer str.deinit();\n"); - try self.buffer.appendSlice(self.allocator, " try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer);\n"); - try self.buffer.appendSlice(self.allocator, " const payload: ?[]const u8 = str.written();\n"); - } else { - try self.buffer.appendSlice(self.allocator, " const payload: ?[]const u8 = null;\n"); + switch (kind) { + .json => { + try self.buffer.appendSlice(self.allocator, "\n var str: std.Io.Writer.Allocating = .init(allocator);\n"); + try self.buffer.appendSlice(self.allocator, " defer str.deinit();\n"); + try self.buffer.appendSlice(self.allocator, " try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer);\n"); + try self.buffer.appendSlice(self.allocator, " const payload: ?[]const u8 = str.written();\n"); + try self.buffer.appendSlice(self.allocator, "\n return requestRaw(client, std.http.Method."); + try self.buffer.appendSlice(self.allocator, method); + try self.buffer.appendSlice(self.allocator, ", uri_buf.written(), payload);\n"); + try self.buffer.appendSlice(self.allocator, "}\n\n"); + return; + }, + .form => { + try self.buffer.appendSlice(self.allocator, " // TODO(#53-followup): multipart/form-data and x-www-form-urlencoded request bodies are not yet supported; falling back to JSON encoding.\n"); + try self.buffer.appendSlice(self.allocator, "\n var str: std.Io.Writer.Allocating = .init(allocator);\n"); + try self.buffer.appendSlice(self.allocator, " defer str.deinit();\n"); + try self.buffer.appendSlice(self.allocator, " try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer);\n"); + try self.buffer.appendSlice(self.allocator, " const payload: ?[]const u8 = str.written();\n"); + try self.buffer.appendSlice(self.allocator, "\n return requestRaw(client, std.http.Method."); + try self.buffer.appendSlice(self.allocator, method); + try self.buffer.appendSlice(self.allocator, ", uri_buf.written(), payload);\n"); + try self.buffer.appendSlice(self.allocator, "}\n\n"); + return; + }, + .none => { + try self.buffer.appendSlice(self.allocator, " const payload: ?[]const u8 = null;\n"); + try self.buffer.appendSlice(self.allocator, "\n return requestRaw(client, std.http.Method."); + try self.buffer.appendSlice(self.allocator, method); + try self.buffer.appendSlice(self.allocator, ", uri_buf.written(), payload);\n"); + try self.buffer.appendSlice(self.allocator, "}\n\n"); + return; + }, + .binary, .text => {}, } - try self.buffer.appendSlice(self.allocator, "\n return requestRaw(client, std.http.Method."); + const body_param = findBodyParam(operation) orelse unreachable; + const ct = body_param.content_type orelse "application/octet-stream"; + try self.buffer.appendSlice(self.allocator, " const payload: ?[]const u8 = requestBody;\n"); + try self.buffer.appendSlice(self.allocator, "\n var headers = std.ArrayList(std.http.Header).empty;\n"); + try self.buffer.appendSlice(self.allocator, " defer headers.deinit(allocator);\n"); + try self.buffer.appendSlice(self.allocator, " const auth_header = try appendClientHeaders(allocator, &headers, client, \""); + try self.buffer.appendSlice(self.allocator, ct); + try self.buffer.appendSlice(self.allocator, "\", \"application/json\");\n"); + try self.buffer.appendSlice(self.allocator, " defer if (auth_header) |value| allocator.free(value);\n"); + try self.buffer.appendSlice(self.allocator, "\n const uri = try std.Uri.parse(uri_buf.written());\n"); + try self.buffer.appendSlice(self.allocator, " var response_body: std.Io.Writer.Allocating = .init(allocator);\n"); + try self.buffer.appendSlice(self.allocator, " defer response_body.deinit();\n"); + try self.buffer.appendSlice(self.allocator, "\n const result = try client.http.fetch(.{\n"); + try self.buffer.appendSlice(self.allocator, " .location = .{ .uri = uri },\n"); + try self.buffer.appendSlice(self.allocator, " .method = std.http.Method."); try self.buffer.appendSlice(self.allocator, method); - try self.buffer.appendSlice(self.allocator, ", uri_buf.written(), payload);\n"); + try self.buffer.appendSlice(self.allocator, ",\n"); + try self.buffer.appendSlice(self.allocator, " .extra_headers = headers.items,\n"); + try self.buffer.appendSlice(self.allocator, " .payload = payload,\n"); + try self.buffer.appendSlice(self.allocator, " .response_writer = &response_body.writer,\n"); + try self.buffer.appendSlice(self.allocator, " });\n"); + try self.buffer.appendSlice(self.allocator, "\n return .{\n"); + try self.buffer.appendSlice(self.allocator, " .allocator = allocator,\n"); + try self.buffer.appendSlice(self.allocator, " .status = result.status,\n"); + try self.buffer.appendSlice(self.allocator, " .body = try response_body.toOwnedSlice(),\n"); + try self.buffer.appendSlice(self.allocator, " };\n"); try self.buffer.appendSlice(self.allocator, "}\n\n"); } @@ -673,7 +739,10 @@ pub const UnifiedApiGenerator = struct { try self.appendIdentifier(name); try self.buffer.appendSlice(self.allocator, ": "); if (param.location == .body) { - if (param.schema) |schema| { + const kind = classifyBody(param.content_type); + if (kind == .binary or kind == .text) { + try self.buffer.appendSlice(self.allocator, "[]const u8"); + } else if (param.schema) |schema| { try self.appendZigTypeFromSchema(schema); } else { try self.buffer.appendSlice(self.allocator, "std.json.Value"); @@ -1066,7 +1135,10 @@ pub const UnifiedApiGenerator = struct { try self.appendParameterName(name, forbidden_names); try self.buffer.appendSlice(self.allocator, ": "); if (param.location == .body) { - if (param.schema) |schema| { + const kind = classifyBody(param.content_type); + if (kind == .binary or kind == .text) { + try self.buffer.appendSlice(self.allocator, "[]const u8"); + } else if (param.schema) |schema| { try self.appendZigTypeFromSchema(schema); } else { try self.buffer.appendSlice(self.allocator, "std.json.Value"); @@ -1289,7 +1361,10 @@ pub const UnifiedApiGenerator = struct { try self.appendIdentifier(name); try self.buffer.appendSlice(self.allocator, ": "); if (param.location == .body) { - if (param.schema) |schema| { + const kind = classifyBody(param.content_type); + if (kind == .binary or kind == .text) { + try self.buffer.appendSlice(self.allocator, "[]const u8"); + } else if (param.schema) |schema| { try self.appendZigTypeFromSchema(schema); } else { try self.buffer.appendSlice(self.allocator, "std.json.Value"); @@ -1363,6 +1438,9 @@ pub const UnifiedApiGenerator = struct { } } } + const direct_kind = bodyKindFor(operation); + const direct_body_param = findBodyParam(operation); + const direct_ct: []const u8 = if (direct_body_param) |p| (p.content_type orelse "application/json") else "application/json"; if (operation.parameters) |parameters| { for (parameters) |parameter| { @@ -1377,7 +1455,13 @@ pub const UnifiedApiGenerator = struct { try self.buffer.appendSlice(self.allocator, " var headers = std.ArrayList(std.http.Header).empty;\n"); try self.buffer.appendSlice(self.allocator, " defer headers.deinit(allocator);\n"); try self.buffer.appendSlice(self.allocator, " const auth_header = try appendClientHeaders(allocator, &headers, client, "); - try self.buffer.appendSlice(self.allocator, if (has_body_param) "\"application/json\"" else "null"); + if (has_body_param) { + try self.buffer.appendSlice(self.allocator, "\""); + try self.buffer.appendSlice(self.allocator, direct_ct); + try self.buffer.appendSlice(self.allocator, "\""); + } else { + try self.buffer.appendSlice(self.allocator, "null"); + } try self.buffer.appendSlice(self.allocator, ", \"application/json\");\n"); try self.buffer.appendSlice(self.allocator, " defer if (auth_header) |value| allocator.free(value);\n\n"); @@ -1459,10 +1543,24 @@ pub const UnifiedApiGenerator = struct { try self.buffer.appendSlice(self.allocator, " const uri = try std.Uri.parse(uri_buf.written());\n"); if (has_body_param) { - try self.buffer.appendSlice(self.allocator, "\n var str: std.Io.Writer.Allocating = .init(allocator);\n"); - try self.buffer.appendSlice(self.allocator, " defer str.deinit();\n\n"); - try self.buffer.appendSlice(self.allocator, " try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer);\n"); - try self.buffer.appendSlice(self.allocator, " const payload = str.written();\n"); + switch (direct_kind) { + .binary, .text => { + try self.buffer.appendSlice(self.allocator, "\n const payload: []const u8 = requestBody;\n"); + }, + .form => { + try self.buffer.appendSlice(self.allocator, " // TODO(#53-followup): multipart/form-data and x-www-form-urlencoded request bodies are not yet supported; falling back to JSON encoding.\n"); + try self.buffer.appendSlice(self.allocator, "\n var str: std.Io.Writer.Allocating = .init(allocator);\n"); + try self.buffer.appendSlice(self.allocator, " defer str.deinit();\n\n"); + try self.buffer.appendSlice(self.allocator, " try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer);\n"); + try self.buffer.appendSlice(self.allocator, " const payload = str.written();\n"); + }, + else => { + try self.buffer.appendSlice(self.allocator, "\n var str: std.Io.Writer.Allocating = .init(allocator);\n"); + try self.buffer.appendSlice(self.allocator, " defer str.deinit();\n\n"); + try self.buffer.appendSlice(self.allocator, " try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer);\n"); + try self.buffer.appendSlice(self.allocator, " const payload = str.written();\n"); + }, + } } const has_return_value = self.hasReturnValue(method, operation); diff --git a/src/tests/binary_payload_tests.zig b/src/tests/binary_payload_tests.zig index c39a239..6c02e2f 100644 --- a/src/tests/binary_payload_tests.zig +++ b/src/tests/binary_payload_tests.zig @@ -307,3 +307,31 @@ test "v2.0 converter :: spec-level consumes inherits when operation omits consum try testing.expect(body.content_type != null); try testing.expectEqualStrings("application/json", body.content_type.?); } + +test "generated v3.0 :: uploadFile takes []const u8 requestBody and emits octet-stream Content-Type" { + const allocator = testing.allocator; + const file_contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, "generated/generated_v3.zig", allocator, .unlimited); + defer allocator.free(file_contents); + + try testing.expect(std.mem.indexOf(u8, file_contents, "pub fn uploadFile(client: *Client, petId: i64, additionalMetadata: ?[]const u8, requestBody: []const u8)") != null); + try testing.expect(std.mem.indexOf(u8, file_contents, "pub fn uploadFileRaw(client: *Client, petId: i64, additionalMetadata: ?[]const u8, requestBody: []const u8)") != null); + // The uploadFileRaw block must NOT JSON-stringify the body. + const upload_idx = std.mem.indexOf(u8, file_contents, "pub fn uploadFileRaw").?; + const upload_end = std.mem.indexOfPos(u8, file_contents, upload_idx, "pub fn uploadFileResult").?; + const upload_block = file_contents[upload_idx..upload_end]; + try testing.expect(std.mem.indexOf(u8, upload_block, "std.json.Stringify.value(requestBody") == null); + try testing.expect(std.mem.indexOf(u8, upload_block, "const payload: ?[]const u8 = requestBody;") != null); + try testing.expect(std.mem.indexOf(u8, upload_block, "\"application/octet-stream\", \"application/json\"") != null); +} + +test "generated v3.0 :: addPet still uses JSON encoding for application/json body" { + const allocator = testing.allocator; + const file_contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, "generated/generated_v3.zig", allocator, .unlimited); + defer allocator.free(file_contents); + + const idx = std.mem.indexOf(u8, file_contents, "pub fn addPetRaw").?; + const end = std.mem.indexOfPos(u8, file_contents, idx, "pub fn addPetResult").?; + const block = file_contents[idx..end]; + try testing.expect(std.mem.indexOf(u8, block, "std.json.Stringify.value(requestBody") != null); + try testing.expect(std.mem.indexOf(u8, block, "requestBody: Pet") != null); +} From f61133ff297976860524a51a3dd30c1d01ea1f57 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Wed, 20 May 2026 17:30:54 +0200 Subject: [PATCH 08/15] docs(readme): document binary request body support and known limits Adds a short Request Body Content Types subsection describing the new []const u8 parameter for binary/text bodies and the JSON-fallback behaviour for multipart/form-data and x-www-form-urlencoded request bodies pending follow-up work. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index d72e613..597eb52 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,12 @@ Generated files are self-contained Zig source files. The current unified generat Parsed JSON responses use `.ignore_unknown_fields = true` so compatible providers can add response fields without breaking callers. Ambiguous or intentionally open-ended schemas use `std.json.Value`; see [`docs/json-value-typing-policy.md`](docs/json-value-typing-policy.md) for the current policy. For OpenAPI 3.1, the converter has stronger composite-schema handling for object/ref `allOf`, preserved `oneOf`/`anyOf` metadata, and nullable type arrays; do not assume every converter has identical composite support. +### Request body content types + +The generator inspects each operation's request body (or Swagger 2.0 `consumes`) and picks the first JSON-flavoured media type when one is available. Bodies classified as binary (`application/octet-stream`, `image/*`, `audio/*`, `video/*`, `*/*`, other `application/*`) or text (`text/*`) generate a `requestBody: []const u8` parameter that is passed straight to `std.http.Client.fetch` with the matching `Content-Type` header — no JSON encoding is applied. + +**Known limitations:** `multipart/form-data` and `application/x-www-form-urlencoded` request bodies are not yet supported. Operations declaring those media types currently fall back to JSON encoding and emit a `TODO(#53-followup)` comment in the generated source; full multipart support is tracked as follow-up work. + ## Example Generated Code The snippets below reflect the current output from `zig build run-generate-v3`. From a8a68312f478a45d422ecc06be651db8aad73eeb Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Wed, 20 May 2026 17:42:22 +0200 Subject: [PATCH 09/15] chore(squad): log issue #53 binary payload work Scribe closeout for binary request-body support (issue #53): - Merged design, test plan, and approval artifacts into decisions.md - Recorded orchestration logs for Lando (design + review), Fenster (implementation), Starkiller (test planning) - Updated agent history files with outcome summary - All validation gates green: zig build test, zig build run-generate - Binary payloads now flow as []const u8 with correct Content-Type headers - JSON bodies unchanged (snapshot stability preserved) --- .squad/agents/fenster/history.md | 4 ++++ .squad/agents/lando/history.md | 31 +++++++++++++++++++++++++++++ .squad/agents/starkiller/history.md | 12 +++++++++++ .squad/decisions.md | 11 ++++++++++ 4 files changed, 58 insertions(+) diff --git a/.squad/agents/fenster/history.md b/.squad/agents/fenster/history.md index 465a4cd..9373da7 100644 --- a/.squad/agents/fenster/history.md +++ b/.squad/agents/fenster/history.md @@ -145,3 +145,7 @@ - Scribe recorded the YAML smoke release as shipped from commits `ac9ed97`, `e6f07ee`, and `55c260e`, preserving the two-layer split between the broad PowerShell sweep and the curated generated harness. - The shared decision log now carries the collision-safe `____` smoke filename rule and the explicit wildcard denylist policy for deterministic pre-wrapper YAML parse failures. + +### 2026-05-20T15:40:56Z — Binary request-body support (issue #53) — 8 atomic commits + +- Implemented all converters + API generator changes for binary request-body support: added `Parameter.content_type` field, updated v2.0/v3.0/v3.1/v3.2 converters to capture media-type strings, introduced `BodyKind` classifier, parameterized `appendClientHeaders` for dynamic Content-Type, and emitted `[]const u8` for binary/text bodies. All 8 commits approved by Lando on `support-binary-payloads-opus` branch. Validation: `zig build test` ✓, `zig build run-generate` ✓, generated `uploadFile` now emits `requestBody: []const u8` + `Content-Type: application/octet-stream`. JSON paths byte-identical. diff --git a/.squad/agents/lando/history.md b/.squad/agents/lando/history.md index c09349c..398a256 100644 --- a/.squad/agents/lando/history.md +++ b/.squad/agents/lando/history.md @@ -141,3 +141,34 @@ efAllDeclsRecursive wrapper if generator gaps slip through. ### 2026-05-01T09:50:14Z — Scribe closeout - Scribe recorded Lando's release approval: the curated-vs-broad smoke split holds, JSON/YAML sibling output collisions are prevented by format-aware names, and the `openapi/v3.2` boundary stays JSON-only until a real YAML root fixture exists. + +### 2026-05-20T15:40:56Z — Binary request-body support (issue #53) — design + review + +- Led cross-functional effort: produced comprehensive design spec for binary request-body support (Parameter.content_type field, media-type capture in converters, BodyKind classifier, Content-Type parameterization, []const u8 emission for binary/text). Reviewed all 8 commits from Fenster against design specification and APPROVED for merge. Outcome: 8 atomic commits on `support-binary-payloads-opus`, all validation gates green, design fully realized with no regressions on JSON paths. + +### 2026-05-20T17:03:44+02:00 — Binary request body design (issue #53) + +- Issue #53 root cause confirmed across two layers: (1) the unified `Parameter` model in `src/models/common/document.zig` has no media-type field, so all converters silently drop the declared content type; (2) `api_generator.zig` at L611 and L1431 unconditionally JSON-stringifies, and `appendClientHeaders` at L507 hard-codes `Content-Type: application/json`. +- Edge-toolkit/core PR #49 reviewed: not a fix template. They post-process generated Zig with tree-sitter to swap a WASM extern; no upstream binary support. We design from scratch. +- Detection placement: parse-time in converters, not generate-time. Adds one allocator-owned `content_type: ?[]const u8` to `Parameter`. Keeps generator dumb; schema sniffing (`format: binary`) is unreliable for Swagger v2 anyway because v2 carries `consumes` operation-level. +- Selection rule (preserves current behavior on the JSON path): JSON wins → else first entry → else null (defaults to JSON downstream). Same rule across all three OpenAPI converters; Swagger converter resolves operation `consumes` then falls back to spec-level `consumes`. +- Generator emits via a private `BodyKind { none, json, binary, text, form }` classifier. Binary/text → `requestBody: []const u8` parameter and raw payload pass-through. `appendClientHeaders` signature changes from `include_content_type: bool` to `content_type: ?[]const u8`. +- Streaming explicitly out of scope for v1: payload stays a single `[]const u8` slice — same lifetime contract as today's stringified JSON buffer. Streaming uploads tracked as a follow-up. +- `multipart/form-data` and `application/x-www-form-urlencoded` are recognized but not implemented; generator emits a `TODO(#53-followup)` comment and falls through to today's behavior so consumers see why uploads silently misbehave. +- `` request bodies stay unsupported (`content_type = null` → JSON default). Following refs for media-type resolution is a much larger refactor and tracked separately. +- Test corpus: petstore v3.0 `uploadFile` is the canonical binary case (octet-stream, format: binary). Petstore v2.0 `uploadFile` is multipart and stays in the "documented limitation" bucket. Snapshot stability for petstore JSON paths (e.g. `addPet`) is the regression gate for C6 and C7. +- Decomposition for Fenster is 8 commits: C1 model field, C2/C3/C4 converters, C5 no-op classifier refactor, C6 header parameterization (byte-identical JSON output), C7 the user-visible fix at both call sites, C8 docs/smoke. JSON path must stay byte-stable except where binary kicks in — if C6 or C7 regress JSON, revert and re-split. +- Christian's standing instruction this stream: small frequent commits, NO `Co-authored-by` trailer. +- Decision recorded: `.squad/decisions/inbox/lando-binary-payloads-design.md`. + +## 2026-05-20T17:03:44+02:00 — Review of #53 (Fenster, branch support-binary-payloads-opus) + +- Verdict: APPROVED. +- 8 commits map 1:1 to design's C1-C8. Memory safety verified: Parameter.content_type alloc.dupe'd in all four converters, freed in Parameter.deinit. No leaks introduced. +- JSON-path: addPet/updatePet signatures and bodies functionally unchanged. Cosmetic whitespace drift in regenerated snapshots on multi-arg print format-args ('{ a, b }' to '{a, b}') and trailing-space comment lines. Smoke + tests + run-generate green. +- Edge cases: '*/*' to binary OK, null/empty content_type to JSON OK, multipart/x-www-form-urlencoded to form fallback with TODO(#53-followup) OK, +json suffix preferred over non-JSON OK. +- Deviations (non-blocking): (a) classifyBody routes any unmatched 'application/*' to binary, broader than design's enumerated list but conservative (JSON-stringify on unknown app types was already broken); (b) swagger_converter.selectConsumesMedia does not skip multipart before falling to list[0] - masked by .form fallback; (c) tests #4 ( body) and #11 (v2 multipart uploadFile TODO) not asserted - #4's behavior preserved by null default, #11 doesn't apply because petstore v2 uploadFile uses formData params (no body, no TODO emitted/expected). +- Implementation note: binary/text *Raw functions inline client.http.fetch instead of delegating to requestRaw (which hard-codes JSON Content-Type). Future cleanup opportunity: requestRawWithContentType helper. +- Commit hygiene: 8 atomic commits, descriptive messages, no Co-authored-by trailers, scoped paths. +- README: known-limitations note matches scope (multipart + form fallback called out). Minor doc gap: \ body limitation not mentioned. Not blocking. +- Approval artifact: .squad/decisions/inbox/lando-binary-payloads-approved.md diff --git a/.squad/agents/starkiller/history.md b/.squad/agents/starkiller/history.md index 8ee8da3..f6a57ec 100644 --- a/.squad/agents/starkiller/history.md +++ b/.squad/agents/starkiller/history.md @@ -50,3 +50,15 @@ ### 2026-05-01T09:50:14Z — Scribe closeout - Scribe recorded Starkiller's acceptance gate: YAML is exercised in both smoke layers, `openapi/v3.2` remains JSON-only, and the remaining denylisted YAML parser/normalization gaps are follow-up backend work rather than blockers for this scoped smoke-coverage release. + +### 2026-05-20T17:03:44+02:00 — Binary payload (#53) test plan drafted + +- Anticipatory draft for binary-payload support landed at `.squad/decisions/inbox/starkiller-binary-payload-test-plan.md`. +- Spec inventory: v3 has petstore `uploadFile` (octet-stream), `api-with-examples` (`*/*`), and hubspot specs as realism checks. **Gap:** no v2.0 spec uses `consumes: [application/octet-stream]` — only `multipart/form-data` (petstore) or `*/*` (api-with-examples). Plan asks Fenster to add `openapi/v2.0/binary-upload.{json,yaml}` and `openapi/v3.0/binary-upload.{json,yaml}` minimal fixtures. +- Test layers identified: (A) unified-IR converter assertions, (B) generated-code substring assertions following the `openapi_v3_tests.zig` / `resource_wrapper_tests.zig` pattern, (C) memory loop test under `test_utils.createTestAllocator()`, (D) smoke harness + `generated/main.zig`, (E) negative/edge (multi-content priority, global vs operation `consumes`, `*/*` default header, multipart unchanged). +- Open questions parked for Fenster: IR field name for body-kind, default Content-Type for `*/*`, multi-content priority rule, generated binary parameter name. +- Quality-gate rule reaffirmed: no new smoke denylist entries permitted for this PR; new fixtures must auto-flow through `comprehensive_converter_tests.zig` directory iteration and `test/smoke-tests.ps1` discovery. + +### 2026-05-20T15:40:56Z — Binary request-body support (issue #53) — test plan locked + +- Confirmed test-plan contract for Fenster's 8-commit implementation: 31 test cases across IR (A1–A10), code-gen output shape (B1–B10), memory safety (C1–C2), smoke/E2E (D1–D4), and edge cases (E1–E5). All A-series assertions map cleanly to converter commits; all B-series output assertions align with generation commits. Validation gates: `zig build test` green, `smoke-tests.ps1` 88-case sweep unchanged (no new denylist entries), no memory leaks under `createTestAllocator()`. Ready for implementation validation post-merge. diff --git a/.squad/decisions.md b/.squad/decisions.md index 2616b69..cbeb64f 100644 --- a/.squad/decisions.md +++ b/.squad/decisions.md @@ -58,3 +58,14 @@ - **Release gate:** Lando and Starkiller approved release as scoped: YAML is exercised in both smoke layers, and remaining denylisted YAML parser/normalization gaps stay explicit follow-up backend work rather than blocking this smoke-coverage expansion. - **Session-scoped directive:** Use GPT-5.5 for the rest of this session only. - **Sources merged:** `copilot-directive-2026-05-01T11-50-14.md`, `fenster-yaml-smoke.md`, `juno-yaml-smoke-docs.md`, `lando-yaml-smoke-verdict.md`, `starkiller-yaml-validation.md`. + +### 2026-05-20: Binary Request Body Support (issue #53) + +- **Decision:** Emit `[]const u8` raw-byte request-body parameters (with appropriate `Content-Type` headers) for specs declaring `application/octet-stream`, `image/*`, `video/*`, `audio/*`, `application/pdf`, and other binary MIME types, instead of JSON-stringifying the binary blob. +- **Architecture:** Add optional `content_type: ?[]const u8` field to the unified `Parameter` model at parse time. Converters capture the chosen media type (JSON-first, then first entry, then null). A private `BodyKind` classifier routes the generator to emit `[]const u8` for binary/text content types, `std.json` for JSON/form, and a TODO comment for multipart (future work). The `appendClientHeaders` runtime helper is parameterized from `bool` to `?[]const u8` to emit the declared `Content-Type` instead of hard-coded `application/json`. +- **Scope (included):** OpenAPI v3.0 / v3.1 / v3.2 and Swagger v2.0 request bodies declared as binary, image, video, audio, PDF, or wildcard (`*/*`). Text bodies get the same `[]const u8` path for consistency. +- **Scope (deferred):** Multipart form data, URL-encoded forms, `$ref` request-body resolution, true streaming uploads. These are documented as follow-ups and emit TODO markers in generated code when encountered. +- **Generated code contract:** Binary/text functions take `requestBody: []const u8` (caller-owned bytes), emit the declared media-type as `Content-Type` header, and pass raw bytes to the HTTP client without JSON serialization. JSON functions remain byte-identical (no snapshot churn except whitespace from Zig 0.16.0 formatting). Multipart/form operations fall back to JSON path + TODO. +- **Test coverage:** Unit tests in `unified_converter_tests.zig` assert IR field capture for binary, JSON, and wildcard content types. Generator tests assert function signatures, header emission, and absence of JSON serialization on the binary path. Memory ownership is validated under `test_utils.createTestAllocator()`. Smoke-test harness (`test/smoke-tests.ps1`) remains green; no new denylist entries required. +- **Session-scoped directive (2026-05-20T17:03:44+02:00, Christian Helle via Copilot):** All agents use Claude Opus 4.7 for this session only. Commits omit `Co-authored-by` trailer for detailed progress history (do not persist as permanent rule). +- **Sources merged:** `lando-binary-payloads-design.md`, `starkiller-binary-payload-test-plan.md`, `lando-binary-payloads-approved.md`, `copilot-directive-20260520T1703.md`. From 3316a40a636d1bdb90d307f0e3e3e259f4991c6c Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Fri, 29 May 2026 19:18:43 +0200 Subject: [PATCH 10/15] test(input_loader): gate unreachable-host test behind integration flag The 'loadFromUrl returns ConnectionFailed for unreachable host' test made a real TCP connection to 192.0.2.1:9999 on every 'zig build test' run. This is non-deterministic: the connect either crashes a worker thread with error.Unexpected (IO_TIMEOUT) during shutdown on Windows, or returns a LoadError other than ConnectionFailed, failing the assertion. Gate it behind skipIntegrationTests() like the other network tests, matching the file's documented intent that network tests are skipped by default to keep unit tests deterministic. --- src/tests/test_input_loader.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tests/test_input_loader.zig b/src/tests/test_input_loader.zig index 666c3ed..0ec3867 100644 --- a/src/tests/test_input_loader.zig +++ b/src/tests/test_input_loader.zig @@ -161,7 +161,14 @@ test "loadFromUrl returns InvalidUrl for unsupported scheme" { try std.testing.expectError(input_loader.LoadError.InvalidUrl, result); } +// @slow - Performs a real TCP connection attempt, so it is gated behind the +// integration flag to keep the default unit test run deterministic and free of +// network side effects (a lingering connect attempt can otherwise time out and +// crash the test process during shutdown on some platforms, e.g. Windows). test "loadFromUrl returns ConnectionFailed for unreachable host" { + const skip_integration = skipIntegrationTests(); + if (skip_integration) return error.SkipZigTest; + var gpa = test_utils.createTestAllocator(); const allocator = gpa.allocator(); defer { From 2773f5ca12f6d2148f9652beddeedebca92c3f5f Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Fri, 29 May 2026 23:18:32 +0200 Subject: [PATCH 11/15] fix(tests): stop writing to stderr during tests so zig build test passes Under zig build test the test executable runs with --listen=- (IPC over stdout via std.zig.Server). On Zig 0.16/Windows, any byte written to stderr from within a test (std.debug.print, or std.log.warn/err which route through it) makes the test process exit nonzero, which the build reports as ailed command: ...test.exe ... --listen=-. std.log.info/debug are filtered at the default testing.log_level (.warn) and write nothing, so they are safe. Changes: - Test files: replace informational std.debug.print with std.log.info (silent at the default test log level). - Leak-detection blocks: replace the print-on-leak with @panic so a leak now fails the test instead of merely printing (and breaking the runner). - generator.zig: remove redundant error-context prints (errors are already surfaced by main.zig) and convert progress messages to std.log.info. - input_loader.zig: convert error-context prints to std.log.info, preserving file/URL context for the CLI while keeping tests silent. Verified: clean-cache zig build test passes with no ailed command; zig build run-generate-v3 and running the generated code still succeed; zig fmt --check passes on LF. --- src/generator.zig | 16 ++---- src/input_loader.zig | 18 +++--- src/tests/comprehensive_converter_tests.zig | 16 +++--- src/tests/openapi_v31_tests.zig | 20 +++---- src/tests/openapi_v32_tests.zig | 20 +++---- src/tests/openapi_v3_tests.zig | 12 ++-- src/tests/swagger_v2_tests.zig | 12 ++-- src/tests/test_input_loader.zig | 62 ++++++++++----------- src/tests/unified_converter_tests.zig | 14 ++--- 9 files changed, 93 insertions(+), 97 deletions(-) diff --git a/src/generator.zig b/src/generator.zig index ebc9db2..c1ed400 100644 --- a/src/generator.zig +++ b/src/generator.zig @@ -35,7 +35,6 @@ pub fn validateExtension(input_file_path: []const u8) !Extension { return Extension.JSON; } - std.debug.print("Invalid file extension. Only json and yaml are supported: \n", .{}); return GeneratorErrors.UnsupportedExtension; } @@ -57,7 +56,6 @@ pub fn generateCode(allocator: std.mem.Allocator, io: std.Io, args: cli.CliArgs) const json_contents = switch (extension) { .YAML => blk: { normalized_yaml_json = yaml_loader.yamlToJson(allocator, file_contents) catch |err| { - std.debug.print("Failed to parse YAML OpenAPI document: {}\n", .{err}); return err; }; break :blk normalized_yaml_json.?; @@ -70,39 +68,37 @@ pub fn generateCode(allocator: std.mem.Allocator, io: std.Io, args: cli.CliArgs) fn generateCodeFromJsonContents(allocator: std.mem.Allocator, io: std.Io, json_contents: []const u8, args: cli.CliArgs) !void { const version = detector.getOpenApiVersion(allocator, json_contents) catch |err| { - std.debug.print("Failed to parse OpenAPI version: {}\n", .{err}); return err; }; - std.debug.print("Detected OpenAPI version: {s}\n", .{detector.getOpenApiVersionString(version)}); + std.log.info("Detected OpenAPI version: {s}", .{detector.getOpenApiVersionString(version)}); switch (version) { .v2_0 => { var swagger = try models.SwaggerDocument.parseFromJson(allocator, json_contents); defer swagger.deinit(allocator); - std.debug.print("Successfully parsed Swagger v2.0 document\n", .{}); + std.log.info("Successfully parsed Swagger v2.0 document", .{}); try generateCodeFromSwaggerDocument(allocator, io, swagger, args); }, .v3_0 => { var openapi = try models.OpenApiDocument.parseFromJson(allocator, json_contents); defer openapi.deinit(allocator); - std.debug.print("Successfully parsed OpenAPI v3.0 document\n", .{}); + std.log.info("Successfully parsed OpenAPI v3.0 document", .{}); try generateCodeFromOpenApiDocument(allocator, io, openapi, args); }, .v3_1 => { var openapi31 = try models.OpenApi31Document.parseFromJson(allocator, json_contents); defer openapi31.deinit(allocator); - std.debug.print("Successfully parsed OpenAPI v3.1 document\n", .{}); + std.log.info("Successfully parsed OpenAPI v3.1 document", .{}); try generateCodeFromOpenApi31Document(allocator, io, openapi31, args); }, .v3_2 => { var openapi32 = try models.OpenApi32Document.parseFromJson(allocator, json_contents); defer openapi32.deinit(allocator); - std.debug.print("Successfully parsed OpenAPI v3.2 document\n", .{}); + std.log.info("Successfully parsed OpenAPI v3.2 document", .{}); try generateCodeFromOpenApi32Document(allocator, io, openapi32, args); }, else => { - std.debug.print("Unsupported OpenAPI version: {s}\n", .{detector.getOpenApiVersionString(version)}); return GeneratorErrors.UnsupportedOpenAPIVersion; }, } @@ -130,7 +126,7 @@ fn generateCodeFromUnifiedDocument(allocator: std.mem.Allocator, io: std.Io, uni const output_file = try cwd.createFile(io, output_path, .{}); defer output_file.close(io); try output_file.writeStreamingAll(io, generated_code); - std.debug.print("Code generated successfully and written to '{s}'.\n", .{output_path}); + std.log.info("Code generated successfully and written to '{s}'.", .{output_path}); } fn generateCodeFromSwaggerDocument(allocator: std.mem.Allocator, io: std.Io, swagger: models.SwaggerDocument, args: cli.CliArgs) !void { diff --git a/src/input_loader.zig b/src/input_loader.zig index cb7f206..80b2552 100644 --- a/src/input_loader.zig +++ b/src/input_loader.zig @@ -30,7 +30,7 @@ pub fn loadInput(allocator: std.mem.Allocator, io: std.Io, source: InputSource) /// Caller owns the returned memory and must free it with allocator.free() pub fn loadFromFile(allocator: std.mem.Allocator, io: std.Io, path: []const u8) ![]const u8 { const contents = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(MAX_BODY_BYTES)) catch |err| { - std.debug.print("Failed to read file '{s}': {}\n", .{ path, err }); + std.log.info("Failed to read file '{s}': {}", .{ path, err }); return err; }; @@ -42,14 +42,14 @@ pub fn loadFromFile(allocator: std.mem.Allocator, io: std.Io, path: []const u8) pub fn loadFromUrl(allocator: std.mem.Allocator, io: std.Io, url: []const u8) ![]const u8 { // Parse URI const uri = std.Uri.parse(url) catch |err| { - std.debug.print("Invalid URL '{s}': {}\n", .{ url, err }); + std.log.info("Invalid URL '{s}': {}", .{ url, err }); return LoadError.InvalidUrl; }; // Verify scheme is http or https const scheme = uri.scheme; if (!std.mem.eql(u8, scheme, "http") and !std.mem.eql(u8, scheme, "https")) { - std.debug.print("Unsupported URL scheme '{s}'. Only http:// and https:// are supported.\n", .{scheme}); + std.log.info("Unsupported URL scheme '{s}'. Only http:// and https:// are supported.", .{scheme}); return LoadError.InvalidUrl; } @@ -58,20 +58,20 @@ pub fn loadFromUrl(allocator: std.mem.Allocator, io: std.Io, url: []const u8) ![ // Create and send request var req = client.request(.GET, uri, .{}) catch |err| { - std.debug.print("Failed to create HTTP request to '{s}': {}\n", .{ url, err }); + std.log.info("Failed to create HTTP request to '{s}': {}", .{ url, err }); return LoadError.ConnectionFailed; }; defer req.deinit(); req.sendBodiless() catch |err| { - std.debug.print("Failed to send HTTP request to '{s}': {}\n", .{ url, err }); + std.log.info("Failed to send HTTP request to '{s}': {}", .{ url, err }); return LoadError.HttpRequestFailed; }; // Receive response headers var redirect_buffer: [1024]u8 = undefined; var response = req.receiveHead(&redirect_buffer) catch |err| { - std.debug.print("Failed to receive HTTP response from '{s}': {}\n", .{ url, err }); + std.log.info("Failed to receive HTTP response from '{s}': {}", .{ url, err }); return LoadError.HttpRequestFailed; }; @@ -79,10 +79,10 @@ pub fn loadFromUrl(allocator: std.mem.Allocator, io: std.Io, url: []const u8) ![ const status = response.head.status; if (status != .ok) { if (status == .not_found) { - std.debug.print("HTTP 404: Resource not found at '{s}'\n", .{url}); + std.log.info("HTTP 404: Resource not found at '{s}'", .{url}); return LoadError.HttpNotFound; } - std.debug.print("HTTP request failed with status {}: {s}\n", .{ @intFromEnum(status), url }); + std.log.info("HTTP request failed with status {}: {s}", .{ @intFromEnum(status), url }); return LoadError.HttpRequestFailed; } @@ -91,7 +91,7 @@ pub fn loadFromUrl(allocator: std.mem.Allocator, io: std.Io, url: []const u8) ![ var transfer_buffer: [4096]u8 = undefined; const reader = response.reader(&transfer_buffer); const body = reader.allocRemaining(allocator, .limited(max_size)) catch |err| { - std.debug.print("Failed to read HTTP response body from '{s}': {}\n", .{ url, err }); + std.log.info("Failed to read HTTP response body from '{s}': {}", .{ url, err }); return LoadError.InvalidResponse; }; diff --git a/src/tests/comprehensive_converter_tests.zig b/src/tests/comprehensive_converter_tests.zig index 0987808..2af788e 100644 --- a/src/tests/comprehensive_converter_tests.zig +++ b/src/tests/comprehensive_converter_tests.zig @@ -31,7 +31,7 @@ fn testOpenApiToUnifiedDocumentConversion(allocator: std.mem.Allocator, file_pat try std.testing.expect(entry.key_ptr.*.len > 0); } } - std.debug.print("✓ OpenAPI v3.0 -> UnifiedDocument: {s} ({s}) - {d} paths\n", .{ unified.info.title, unified.version, unified.paths.count() }); + std.log.info("✓ OpenAPI v3.0 -> UnifiedDocument: {s} ({s}) - {d} paths\n", .{ unified.info.title, unified.version, unified.paths.count() }); } fn testSwaggerToUnifiedDocumentConversion(allocator: std.mem.Allocator, file_path: []const u8) !void { @@ -49,7 +49,7 @@ fn testSwaggerToUnifiedDocumentConversion(allocator: std.mem.Allocator, file_pat try std.testing.expect(entry.key_ptr.*.len > 0); } } - std.debug.print("✓ Swagger v2.0 -> UnifiedDocument: {s} ({s}) - {d} paths\n", .{ unified.info.title, unified.version, unified.paths.count() }); + std.log.info("✓ Swagger v2.0 -> UnifiedDocument: {s} ({s}) - {d} paths\n", .{ unified.info.title, unified.version, unified.paths.count() }); } test "dynamically convert all OpenAPI v3.0 JSON files to UnifiedDocument" { @@ -67,12 +67,12 @@ test "dynamically convert all OpenAPI v3.0 JSON files to UnifiedDocument" { var path_buffer: [256]u8 = undefined; const full_path = try std.fmt.bufPrint(path_buffer[0..], "openapi/v3.0/{s}", .{entry.name}); testOpenApiToUnifiedDocumentConversion(allocator, full_path) catch |err| { - std.debug.print("✗ Failed to convert OpenAPI v3.0 {s}: {}\n", .{ full_path, err }); + std.log.info("✗ Failed to convert OpenAPI v3.0 {s}: {}\n", .{ full_path, err }); continue; }; successful_conversions += 1; } - std.debug.print("Dynamic OpenAPI v3.0 test: {d}/{d} files converted successfully\n", .{ successful_conversions, total_files }); + std.log.info("Dynamic OpenAPI v3.0 test: {d}/{d} files converted successfully\n", .{ successful_conversions, total_files }); try std.testing.expect(successful_conversions == total_files); try std.testing.expect(total_files > 0); } @@ -92,12 +92,12 @@ test "dynamically convert all Swagger v2.0 JSON files to UnifiedDocument" { var path_buffer: [256]u8 = undefined; const full_path = try std.fmt.bufPrint(path_buffer[0..], "openapi/v2.0/{s}", .{entry.name}); testSwaggerToUnifiedDocumentConversion(allocator, full_path) catch |err| { - std.debug.print("✗ Failed to convert Swagger v2.0 {s}: {}\n", .{ full_path, err }); + std.log.info("✗ Failed to convert Swagger v2.0 {s}: {}\n", .{ full_path, err }); continue; }; successful_conversions += 1; } - std.debug.print("Dynamic Swagger v2.0 test: {d}/{d} files converted successfully\n", .{ successful_conversions, total_files }); + std.log.info("Dynamic Swagger v2.0 test: {d}/{d} files converted successfully\n", .{ successful_conversions, total_files }); try std.testing.expect(successful_conversions == total_files); try std.testing.expect(total_files > 0); } @@ -124,8 +124,8 @@ test "memory leak stress test for converters" { try std.testing.expect(unified.info.title.len > 0); } if ((i + 1) % 10 == 0) { - std.debug.print("Completed {d}/{d} stress test iterations\n", .{ i + 1, iterations }); + std.log.info("Completed {d}/{d} stress test iterations\n", .{ i + 1, iterations }); } } - std.debug.print("✓ Memory leak stress test passed: {d} iterations completed successfully\n", .{iterations}); + std.log.info("✓ Memory leak stress test passed: {d} iterations completed successfully\n", .{iterations}); } diff --git a/src/tests/openapi_v31_tests.zig b/src/tests/openapi_v31_tests.zig index e4cfee4..f81e59c 100644 --- a/src/tests/openapi_v31_tests.zig +++ b/src/tests/openapi_v31_tests.zig @@ -14,7 +14,7 @@ fn testOpenApi31DocumentParsing(allocator: std.mem.Allocator, file_path: []const defer parsed.deinit(allocator); try std.testing.expect(parsed.openapi.len > 0); try std.testing.expect(parsed.info.title.len > 0); - std.debug.print("Successfully parsed OpenAPI 3.1 document from {s}: {s} (version: {s})\n", .{ file_path, parsed.info.title, parsed.openapi }); + std.log.info("Successfully parsed OpenAPI 3.1 document from {s}: {s} (version: {s})\n", .{ file_path, parsed.info.title, parsed.openapi }); } test "can deserialize non-oauth-scopes into OpenApi31Document" { @@ -83,12 +83,12 @@ test "can parse all v3.1 JSON OpenAPI specifications" { var successful_parses: u32 = 0; for (json_files) |file_path| { testOpenApi31DocumentParsing(allocator, file_path) catch |err| { - std.debug.print("Failed to parse {s}: {}\n", .{ file_path, err }); + std.log.info("Failed to parse {s}: {}\n", .{ file_path, err }); continue; }; successful_parses += 1; } - std.debug.print("Successfully parsed {d}/{d} JSON OpenAPI 3.1 specifications\n", .{ successful_parses, json_files.len }); + std.log.info("Successfully parsed {d}/{d} JSON OpenAPI 3.1 specifications\n", .{ successful_parses, json_files.len }); try std.testing.expect(successful_parses == json_files.len); } @@ -101,7 +101,7 @@ fn testOpenApi31ToUnifiedDocumentConversion(allocator: std.mem.Allocator, file_p try std.testing.expect(unified.version.len > 0); try std.testing.expect(unified.info.title.len > 0); try std.testing.expect(std.mem.startsWith(u8, unified.version, "3.1")); - std.debug.print("Successfully converted OpenAPI 3.1 document from {s}: {s} (version: {s})\n", .{ file_path, unified.info.title, unified.version }); + std.log.info("Successfully converted OpenAPI 3.1 document from {s}: {s} (version: {s})\n", .{ file_path, unified.info.title, unified.version }); } test "can convert non-oauth-scopes.json to UnifiedDocument" { @@ -171,12 +171,12 @@ test "can convert all v3.1 JSON OpenAPI specifications to UnifiedDocument" { var successful_conversions: u32 = 0; for (json_files) |file_path| { testOpenApi31ToUnifiedDocumentConversion(allocator, file_path) catch |err| { - std.debug.print("Failed to convert {s}: {}\n", .{ file_path, err }); + std.log.info("Failed to convert {s}: {}\n", .{ file_path, err }); continue; }; successful_conversions += 1; } - std.debug.print("Successfully converted {d}/{d} JSON OpenAPI v3.1 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); + std.log.info("Successfully converted {d}/{d} JSON OpenAPI v3.1 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); try std.testing.expect(successful_conversions == json_files.len); } @@ -195,12 +195,12 @@ test "dynamically convert all OpenAPI v3.1 JSON files to UnifiedDocument" { var path_buffer: [256]u8 = undefined; const full_path = try std.fmt.bufPrint(path_buffer[0..], "openapi/v3.1/{s}", .{entry.name}); testOpenApi31ToUnifiedDocumentConversion(allocator, full_path) catch |err| { - std.debug.print("✗ Failed to convert OpenAPI v3.1 {s}: {}\n", .{ full_path, err }); + std.log.info("✗ Failed to convert OpenAPI v3.1 {s}: {}\n", .{ full_path, err }); continue; }; successful_conversions += 1; } - std.debug.print("Dynamic OpenAPI v3.1 test: {d}/{d} files converted successfully\n", .{ successful_conversions, total_files }); + std.log.info("Dynamic OpenAPI v3.1 test: {d}/{d} files converted successfully\n", .{ successful_conversions, total_files }); try std.testing.expect(successful_conversions == total_files); try std.testing.expect(total_files > 0); } @@ -219,8 +219,8 @@ test "memory leak stress test for v3.1 converter" { try std.testing.expect(unified.info.title.len > 0); } if ((i + 1) % 10 == 0) { - std.debug.print("Completed {d}/{d} v3.1 stress test iterations\n", .{ i + 1, iterations }); + std.log.info("Completed {d}/{d} v3.1 stress test iterations\n", .{ i + 1, iterations }); } } - std.debug.print("✓ v3.1 memory leak stress test passed: {d} iterations completed successfully\n", .{iterations}); + std.log.info("✓ v3.1 memory leak stress test passed: {d} iterations completed successfully\n", .{iterations}); } diff --git a/src/tests/openapi_v32_tests.zig b/src/tests/openapi_v32_tests.zig index 6603853..c82d1e1 100644 --- a/src/tests/openapi_v32_tests.zig +++ b/src/tests/openapi_v32_tests.zig @@ -14,7 +14,7 @@ fn testOpenApi32DocumentParsing(allocator: std.mem.Allocator, file_path: []const defer parsed.deinit(allocator); try std.testing.expect(parsed.openapi.len > 0); try std.testing.expect(parsed.info.title.len > 0); - std.debug.print("Successfully parsed OpenAPI 3.2 document from {s}: {s} (version: {s})\n", .{ file_path, parsed.info.title, parsed.openapi }); + std.log.info("Successfully parsed OpenAPI 3.2 document from {s}: {s} (version: {s})\n", .{ file_path, parsed.info.title, parsed.openapi }); } test "can deserialize petstore into OpenApi32Document" { @@ -156,12 +156,12 @@ test "can parse all v3.2 JSON OpenAPI specifications" { var successful_parses: u32 = 0; for (json_files) |file_path| { testOpenApi32DocumentParsing(allocator, file_path) catch |err| { - std.debug.print("Failed to parse {s}: {}\n", .{ file_path, err }); + std.log.info("Failed to parse {s}: {}\n", .{ file_path, err }); continue; }; successful_parses += 1; } - std.debug.print("Successfully parsed {d}/{d} JSON OpenAPI 3.2 specifications\n", .{ successful_parses, json_files.len }); + std.log.info("Successfully parsed {d}/{d} JSON OpenAPI 3.2 specifications\n", .{ successful_parses, json_files.len }); try std.testing.expect(successful_parses == json_files.len); } @@ -174,7 +174,7 @@ fn testOpenApi32ToUnifiedDocumentConversion(allocator: std.mem.Allocator, file_p try std.testing.expect(unified.version.len > 0); try std.testing.expect(unified.info.title.len > 0); try std.testing.expect(std.mem.startsWith(u8, unified.version, "3.2")); - std.debug.print("Successfully converted OpenAPI 3.2 document from {s}: {s} (version: {s})\n", .{ file_path, unified.info.title, unified.version }); + std.log.info("Successfully converted OpenAPI 3.2 document from {s}: {s} (version: {s})\n", .{ file_path, unified.info.title, unified.version }); } test "can convert petstore.json to UnifiedDocument" { @@ -290,12 +290,12 @@ test "can convert all v3.2 JSON OpenAPI specifications to UnifiedDocument" { var successful_conversions: u32 = 0; for (json_files) |file_path| { testOpenApi32ToUnifiedDocumentConversion(allocator, file_path) catch |err| { - std.debug.print("Failed to convert {s}: {}\n", .{ file_path, err }); + std.log.info("Failed to convert {s}: {}\n", .{ file_path, err }); continue; }; successful_conversions += 1; } - std.debug.print("Successfully converted {d}/{d} JSON OpenAPI v3.2 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); + std.log.info("Successfully converted {d}/{d} JSON OpenAPI v3.2 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); try std.testing.expect(successful_conversions == json_files.len); } @@ -314,12 +314,12 @@ test "dynamically convert all OpenAPI v3.2 JSON files to UnifiedDocument" { var path_buffer: [256]u8 = undefined; const full_path = try std.fmt.bufPrint(path_buffer[0..], "openapi/v3.2/{s}", .{entry.name}); testOpenApi32ToUnifiedDocumentConversion(allocator, full_path) catch |err| { - std.debug.print("✗ Failed to convert OpenAPI v3.2 {s}: {}\n", .{ full_path, err }); + std.log.info("✗ Failed to convert OpenAPI v3.2 {s}: {}\n", .{ full_path, err }); continue; }; successful_conversions += 1; } - std.debug.print("Dynamic OpenAPI v3.2 test: {d}/{d} files converted successfully\n", .{ successful_conversions, total_files }); + std.log.info("Dynamic OpenAPI v3.2 test: {d}/{d} files converted successfully\n", .{ successful_conversions, total_files }); try std.testing.expect(successful_conversions == total_files); try std.testing.expect(total_files > 0); } @@ -338,8 +338,8 @@ test "memory leak stress test for v3.2 converter" { try std.testing.expect(unified.info.title.len > 0); } if ((i + 1) % 10 == 0) { - std.debug.print("Completed {d}/{d} v3.2 stress test iterations\n", .{ i + 1, iterations }); + std.log.info("Completed {d}/{d} v3.2 stress test iterations\n", .{ i + 1, iterations }); } } - std.debug.print("✓ v3.2 memory leak stress test passed: {d} iterations completed successfully\n", .{iterations}); + std.log.info("✓ v3.2 memory leak stress test passed: {d} iterations completed successfully\n", .{iterations}); } diff --git a/src/tests/openapi_v3_tests.zig b/src/tests/openapi_v3_tests.zig index e07818d..7a61e37 100644 --- a/src/tests/openapi_v3_tests.zig +++ b/src/tests/openapi_v3_tests.zig @@ -15,7 +15,7 @@ fn testOpenApiDocumentParsing(allocator: std.mem.Allocator, file_path: []const u defer parsed.deinit(allocator); try std.testing.expect(parsed.openapi.len > 0); try std.testing.expect(parsed.info.title.len > 0); - std.debug.print("Successfully parsed OpenAPI document from {s}: {s} (version: {s})\n", .{ file_path, parsed.info.title, parsed.openapi }); + std.log.info("Successfully parsed OpenAPI document from {s}: {s} (version: {s})\n", .{ file_path, parsed.info.title, parsed.openapi }); } test "can deserialize petstore into OpenApiDocument" { @@ -119,12 +119,12 @@ test "can parse all v3.0 JSON OpenAPI specifications" { var successful_parses: u32 = 0; for (json_files) |file_path| { testOpenApiDocumentParsing(allocator, file_path) catch |err| { - std.debug.print("Failed to parse {s}: {}\n", .{ file_path, err }); + std.log.info("Failed to parse {s}: {}\n", .{ file_path, err }); continue; }; successful_parses += 1; } - std.debug.print("Successfully parsed {d}/{d} JSON OpenAPI specifications\n", .{ successful_parses, json_files.len }); + std.log.info("Successfully parsed {d}/{d} JSON OpenAPI specifications\n", .{ successful_parses, json_files.len }); try std.testing.expect(successful_parses > 0); } @@ -136,7 +136,7 @@ fn testOpenApiToUnifiedDocumentConversion(allocator: std.mem.Allocator, file_pat defer unified.deinit(allocator); try std.testing.expect(unified.version.len > 0); try std.testing.expect(unified.info.title.len > 0); - std.debug.print("Successfully converted OpenAPI document from {s}: {s} (version: {s})\n", .{ file_path, unified.info.title, unified.version }); + std.log.info("Successfully converted OpenAPI document from {s}: {s} (version: {s})\n", .{ file_path, unified.info.title, unified.version }); } test "can convert api-with-examples.json to UnifiedDocument" { @@ -217,11 +217,11 @@ test "can convert all v3.0 JSON OpenAPI specifications to UnifiedDocument" { var successful_conversions: u32 = 0; for (json_files) |file_path| { testOpenApiToUnifiedDocumentConversion(allocator, file_path) catch |err| { - std.debug.print("Failed to convert {s}: {}\n", .{ file_path, err }); + std.log.info("Failed to convert {s}: {}\n", .{ file_path, err }); continue; }; successful_conversions += 1; } - std.debug.print("Successfully converted {d}/{d} JSON OpenAPI v3.0 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); + std.log.info("Successfully converted {d}/{d} JSON OpenAPI v3.0 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); try std.testing.expect(successful_conversions > 0); } diff --git a/src/tests/swagger_v2_tests.zig b/src/tests/swagger_v2_tests.zig index b7dce7e..114bf72 100644 --- a/src/tests/swagger_v2_tests.zig +++ b/src/tests/swagger_v2_tests.zig @@ -14,7 +14,7 @@ fn testSwaggerDocumentParsing(allocator: std.mem.Allocator, file_path: []const u defer parsed.deinit(allocator); try std.testing.expect(parsed.swagger.len > 0); try std.testing.expect(parsed.info.title.len > 0); - std.debug.print("Successfully parsed Swagger document from {s}: {s} (version: {s})\n", .{ file_path, parsed.info.title, parsed.swagger }); + std.log.info("Successfully parsed Swagger document from {s}: {s} (version: {s})\n", .{ file_path, parsed.info.title, parsed.swagger }); } test "can deserialize v2.0 petstore into SwaggerDocument" { @@ -83,12 +83,12 @@ test "can parse all v2.0 JSON Swagger specifications" { var successful_parses: u32 = 0; for (json_files) |file_path| { testSwaggerDocumentParsing(allocator, file_path) catch |err| { - std.debug.print("Failed to parse {s}: {}\n", .{ file_path, err }); + std.log.info("Failed to parse {s}: {}\n", .{ file_path, err }); continue; }; successful_parses += 1; } - std.debug.print("Successfully parsed {d}/{d} JSON Swagger v2.0 specifications\n", .{ successful_parses, json_files.len }); + std.log.info("Successfully parsed {d}/{d} JSON Swagger v2.0 specifications\n", .{ successful_parses, json_files.len }); try std.testing.expect(successful_parses > 0); } @@ -100,7 +100,7 @@ fn testSwaggerToUnifiedDocumentConversion(allocator: std.mem.Allocator, file_pat defer unified.deinit(allocator); try std.testing.expect(unified.version.len > 0); try std.testing.expect(unified.info.title.len > 0); - std.debug.print("Successfully converted Swagger document from {s}: {s} (version: {s})\n", .{ file_path, unified.info.title, unified.version }); + std.log.info("Successfully converted Swagger document from {s}: {s} (version: {s})\n", .{ file_path, unified.info.title, unified.version }); } test "can convert v2.0 api-with-examples.json to UnifiedDocument" { @@ -160,11 +160,11 @@ test "can convert all v2.0 JSON Swagger specifications to UnifiedDocument" { var successful_conversions: u32 = 0; for (json_files) |file_path| { testSwaggerToUnifiedDocumentConversion(allocator, file_path) catch |err| { - std.debug.print("Failed to convert {s}: {}\n", .{ file_path, err }); + std.log.info("Failed to convert {s}: {}\n", .{ file_path, err }); continue; }; successful_conversions += 1; } - std.debug.print("Successfully converted {d}/{d} JSON Swagger v2.0 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); + std.log.info("Successfully converted {d}/{d} JSON Swagger v2.0 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); try std.testing.expect(successful_conversions > 0); } diff --git a/src/tests/test_input_loader.zig b/src/tests/test_input_loader.zig index 0ec3867..7715f93 100644 --- a/src/tests/test_input_loader.zig +++ b/src/tests/test_input_loader.zig @@ -45,7 +45,7 @@ test "loadFromFile loads OpenAPI v3.0 petstore spec" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -63,7 +63,7 @@ test "loadFromFile loads Swagger v2.0 petstore spec" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -81,7 +81,7 @@ test "loadFromFile returns error for non-existent file" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -99,7 +99,7 @@ test "loadInput with file_path source loads v3.0 spec" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -117,7 +117,7 @@ test "loadInput with file_path source loads v2.0 spec" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -139,7 +139,7 @@ test "loadFromUrl returns InvalidUrl for invalid URL syntax" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -153,7 +153,7 @@ test "loadFromUrl returns InvalidUrl for unsupported scheme" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -174,7 +174,7 @@ test "loadFromUrl returns ConnectionFailed for unreachable host" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -232,25 +232,25 @@ fn testFullPipelineFromFile(allocator: std.mem.Allocator, file_path: []const u8, var swagger = try models.SwaggerDocument.parseFromJson(allocator, contents); defer swagger.deinit(allocator); try std.testing.expect(swagger.info.title.len > 0); - std.debug.print("✓ Full pipeline (file): {s} - Swagger v2.0\n", .{swagger.info.title}); + std.log.info("✓ Full pipeline (file): {s} - Swagger v2.0\n", .{swagger.info.title}); }, .v3_0 => { var openapi = try models.OpenApiDocument.parseFromJson(allocator, contents); defer openapi.deinit(allocator); try std.testing.expect(openapi.info.title.len > 0); - std.debug.print("✓ Full pipeline (file): {s} - OpenAPI v3.0\n", .{openapi.info.title}); + std.log.info("✓ Full pipeline (file): {s} - OpenAPI v3.0\n", .{openapi.info.title}); }, .v3_1 => { var openapi31 = try models.OpenApi31Document.parseFromJson(allocator, contents); defer openapi31.deinit(allocator); try std.testing.expect(openapi31.info.title.len > 0); - std.debug.print("✓ Full pipeline (file): {s} - OpenAPI v3.1\n", .{openapi31.info.title}); + std.log.info("✓ Full pipeline (file): {s} - OpenAPI v3.1\n", .{openapi31.info.title}); }, .v3_2 => { var openapi32 = try models.OpenApi32Document.parseFromJson(allocator, contents); defer openapi32.deinit(allocator); try std.testing.expect(openapi32.info.title.len > 0); - std.debug.print("✓ Full pipeline (file): {s} - OpenAPI v3.2\n", .{openapi32.info.title}); + std.log.info("✓ Full pipeline (file): {s} - OpenAPI v3.2\n", .{openapi32.info.title}); }, .Unsupported => return error.UnsupportedVersion, } @@ -262,7 +262,7 @@ test "full pipeline: file load -> parse -> validate (v3.0 petstore)" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -275,7 +275,7 @@ test "full pipeline: file load -> parse -> validate (v2.0 petstore)" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -288,7 +288,7 @@ test "full pipeline: file load -> parse -> validate (v3.0 api-with-examples)" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -301,7 +301,7 @@ test "full pipeline: file load -> parse -> validate (v2.0 api-with-examples)" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in test!\n", .{}); + @panic("Memory leak detected in test!"); } } @@ -338,28 +338,28 @@ fn testFullPipelineFromUrl(allocator: std.mem.Allocator, url: []const u8, expect var swagger = try models.SwaggerDocument.parseFromJson(allocator, contents); defer swagger.deinit(allocator); try std.testing.expect(swagger.info.title.len > 0); - std.debug.print("✓ Full pipeline (URL): {s} - Swagger v2.0 from {s}\n", .{ swagger.info.title, url }); + std.log.info("✓ Full pipeline (URL): {s} - Swagger v2.0 from {s}\n", .{ swagger.info.title, url }); }, .v3_0 => { var openapi = try models.OpenApiDocument.parseFromJson(allocator, contents); defer openapi.deinit(allocator); try std.testing.expect(openapi.info.title.len > 0); - std.debug.print("✓ Full pipeline (URL): {s} - OpenAPI v3.0 from {s}\n", .{ openapi.info.title, url }); + std.log.info("✓ Full pipeline (URL): {s} - OpenAPI v3.0 from {s}\n", .{ openapi.info.title, url }); }, .v3_1 => { var openapi31 = try models.OpenApi31Document.parseFromJson(allocator, contents); defer openapi31.deinit(allocator); try std.testing.expect(openapi31.info.title.len > 0); - std.debug.print("✓ Full pipeline (URL): {s} - OpenAPI v3.1 from {s}\n", .{ openapi31.info.title, url }); + std.log.info("✓ Full pipeline (URL): {s} - OpenAPI v3.1 from {s}\n", .{ openapi31.info.title, url }); }, .v3_2 => { var openapi32 = try models.OpenApi32Document.parseFromJson(allocator, contents); defer openapi32.deinit(allocator); try std.testing.expect(openapi32.info.title.len > 0); - std.debug.print("✓ Full pipeline (URL): {s} - OpenAPI v3.2 from {s}\n", .{ openapi32.info.title, url }); + std.log.info("✓ Full pipeline (URL): {s} - OpenAPI v3.2 from {s}\n", .{ openapi32.info.title, url }); }, .Unsupported => { - std.debug.print("✗ Unsupported OpenAPI version detected from URL: {s}\n", .{url}); + std.log.info("✗ Unsupported OpenAPI version detected from URL: {s}\n", .{url}); return error.UnsupportedVersion; }, } @@ -377,7 +377,7 @@ test "integration: load OpenAPI v3.0 from public petstore URL" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in integration test!\n", .{}); + @panic("Memory leak detected in integration test!"); } } @@ -398,7 +398,7 @@ test "integration: load Swagger v2.0 from public petstore URL" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in integration test!\n", .{}); + @panic("Memory leak detected in integration test!"); } } @@ -419,13 +419,13 @@ test "integration: loadFromUrl handles 404 not found" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in integration test!\n", .{}); + @panic("Memory leak detected in integration test!"); } } const result = input_loader.loadFromUrl(allocator, std.testing.io, "https://petstore3.swagger.io/api/v3/nonexistent.json"); try std.testing.expectError(input_loader.LoadError.HttpNotFound, result); - std.debug.print("✓ 404 error handling verified\n", .{}); + std.log.info("✓ 404 error handling verified\n", .{}); } // ============================================================================ @@ -441,7 +441,7 @@ test "file and URL loading produce equivalent results for v3.0" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in comparison test!\n", .{}); + @panic("Memory leak detected in comparison test!"); } } @@ -467,7 +467,7 @@ test "file and URL loading produce equivalent results for v3.0" { try std.testing.expect(file_doc.info.title.len > 0); try std.testing.expect(url_doc.info.title.len > 0); - std.debug.print("✓ File and URL loading consistency verified for v3.0\n", .{}); + std.log.info("✓ File and URL loading consistency verified for v3.0\n", .{}); } test "file and URL loading produce equivalent results for v2.0" { @@ -479,7 +479,7 @@ test "file and URL loading produce equivalent results for v2.0" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in comparison test!\n", .{}); + @panic("Memory leak detected in comparison test!"); } } @@ -505,7 +505,7 @@ test "file and URL loading produce equivalent results for v2.0" { try std.testing.expect(file_doc.info.title.len > 0); try std.testing.expect(url_doc.info.title.len > 0); - std.debug.print("✓ File and URL loading consistency verified for v2.0\n", .{}); + std.log.info("✓ File and URL loading consistency verified for v2.0\n", .{}); } // ============================================================================ @@ -518,7 +518,7 @@ test "loadFromFile handles large files without memory issues" { defer { const deinit_status = gpa.deinit(); if (deinit_status == .leak) { - std.debug.print("Memory leak detected in large file test!\n", .{}); + @panic("Memory leak detected in large file test!"); } } @@ -530,7 +530,7 @@ test "loadFromFile handles large files without memory issues" { var parsed = try models.OpenApiDocument.parseFromJson(allocator, contents); defer parsed.deinit(allocator); - std.debug.print("✓ Large file handling verified ({d} bytes)\n", .{contents.len}); + std.log.info("✓ Large file handling verified ({d} bytes)\n", .{contents.len}); } test "InputSource discriminates between file_path and url correctly" { diff --git a/src/tests/unified_converter_tests.zig b/src/tests/unified_converter_tests.zig index a115c55..841540f 100644 --- a/src/tests/unified_converter_tests.zig +++ b/src/tests/unified_converter_tests.zig @@ -25,7 +25,7 @@ fn testOpenApiToUnifiedDocumentConversion(allocator: std.mem.Allocator, file_pat try std.testing.expect(unified.version.len > 0); try std.testing.expect(unified.info.title.len > 0); try std.testing.expect(std.mem.startsWith(u8, unified.version, "3.")); - std.debug.print("OpenAPI v3.0 -> UnifiedDocument: {s} ({s})\n", .{ unified.info.title, unified.version }); + std.log.info("OpenAPI v3.0 -> UnifiedDocument: {s} ({s})\n", .{ unified.info.title, unified.version }); } fn testSwaggerToUnifiedDocumentConversion(allocator: std.mem.Allocator, file_path: []const u8) !void { @@ -37,7 +37,7 @@ fn testSwaggerToUnifiedDocumentConversion(allocator: std.mem.Allocator, file_pat try std.testing.expect(unified.version.len > 0); try std.testing.expect(unified.info.title.len > 0); try std.testing.expectEqualStrings("2.0", unified.version); - std.debug.print("Swagger v2.0 -> UnifiedDocument: {s} ({s})\n", .{ unified.info.title, unified.version }); + std.log.info("Swagger v2.0 -> UnifiedDocument: {s} ({s})\n", .{ unified.info.title, unified.version }); } test "convert all OpenAPI v3.0 JSON specifications to UnifiedDocument" { @@ -58,12 +58,12 @@ test "convert all OpenAPI v3.0 JSON specifications to UnifiedDocument" { var successful_conversions: u32 = 0; for (json_files) |file_path| { testOpenApiToUnifiedDocumentConversion(allocator, file_path) catch |err| { - std.debug.print("Failed to convert OpenAPI v3.0 {s}: {}\n", .{ file_path, err }); + std.log.info("Failed to convert OpenAPI v3.0 {s}: {}\n", .{ file_path, err }); continue; }; successful_conversions += 1; } - std.debug.print("Successfully converted {d}/{d} OpenAPI v3.0 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); + std.log.info("Successfully converted {d}/{d} OpenAPI v3.0 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); try std.testing.expect(successful_conversions == json_files.len); } @@ -82,12 +82,12 @@ test "convert all Swagger v2.0 JSON specifications to UnifiedDocument" { var successful_conversions: u32 = 0; for (json_files) |file_path| { testSwaggerToUnifiedDocumentConversion(allocator, file_path) catch |err| { - std.debug.print("Failed to convert Swagger v2.0 {s}: {}\n", .{ file_path, err }); + std.log.info("Failed to convert Swagger v2.0 {s}: {}\n", .{ file_path, err }); continue; }; successful_conversions += 1; } - std.debug.print("Successfully converted {d}/{d} Swagger v2.0 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); + std.log.info("Successfully converted {d}/{d} Swagger v2.0 specifications to UnifiedDocument\n", .{ successful_conversions, json_files.len }); try std.testing.expect(successful_conversions == json_files.len); } @@ -110,5 +110,5 @@ test "unified conversion compatibility between Swagger v2.0 and OpenAPI v3.0" { try std.testing.expectEqualStrings("3.0.2", openapi_unified.version); try std.testing.expect(swagger_unified.paths.count() > 0); try std.testing.expect(openapi_unified.paths.count() > 0); - std.debug.print("Unified conversion test passed: Swagger v2.0 ({d} paths) and OpenAPI v3.0 ({d} paths) both converted successfully\n", .{ swagger_unified.paths.count(), openapi_unified.paths.count() }); + std.log.info("Unified conversion test passed: Swagger v2.0 ({d} paths) and OpenAPI v3.0 ({d} paths) both converted successfully\n", .{ swagger_unified.paths.count(), openapi_unified.paths.count() }); } From 94974764078e33dd7fcbbdf4b3e52aaccd071670 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Sat, 30 May 2026 12:17:46 +0200 Subject: [PATCH 12/15] feat: remove "type" from list of reserved keywords --- generated/generated_v2.zig | 2 +- generated/generated_v2_yaml.zig | 2 +- generated/generated_v3.zig | 2 +- generated/generated_v32.zig | 2 +- generated/generated_v3_yaml.zig | 2 +- src/generators/unified/model_generator.zig | 16 ++++++++-------- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/generated/generated_v2.zig b/generated/generated_v2.zig index c9f3924..58b1afa 100644 --- a/generated/generated_v2.zig +++ b/generated/generated_v2.zig @@ -44,7 +44,7 @@ pub const Order = struct { }; pub const ApiResponse = struct { - @"type": ?[]const u8 = null, + type: ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; diff --git a/generated/generated_v2_yaml.zig b/generated/generated_v2_yaml.zig index e6a0698..ef79546 100644 --- a/generated/generated_v2_yaml.zig +++ b/generated/generated_v2_yaml.zig @@ -19,7 +19,7 @@ pub const Pet = struct { }; pub const ApiResponse = struct { - @"type": ?[]const u8 = null, + type: ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; diff --git a/generated/generated_v3.zig b/generated/generated_v3.zig index 3109c50..fb8a4a6 100644 --- a/generated/generated_v3.zig +++ b/generated/generated_v3.zig @@ -57,7 +57,7 @@ pub const User = struct { }; pub const ApiResponse = struct { - @"type": ?[]const u8 = null, + type: ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; diff --git a/generated/generated_v32.zig b/generated/generated_v32.zig index 3e72b8d..8bc233c 100644 --- a/generated/generated_v32.zig +++ b/generated/generated_v32.zig @@ -44,7 +44,7 @@ pub const Order = struct { }; pub const ApiResponse = struct { - @"type": ?[]const u8 = null, + type: ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; diff --git a/generated/generated_v3_yaml.zig b/generated/generated_v3_yaml.zig index 3109c50..fb8a4a6 100644 --- a/generated/generated_v3_yaml.zig +++ b/generated/generated_v3_yaml.zig @@ -57,7 +57,7 @@ pub const User = struct { }; pub const ApiResponse = struct { - @"type": ?[]const u8 = null, + type: ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; diff --git a/src/generators/unified/model_generator.zig b/src/generators/unified/model_generator.zig index 44fb066..cc02621 100644 --- a/src/generators/unified/model_generator.zig +++ b/src/generators/unified/model_generator.zig @@ -12,14 +12,14 @@ fn isIdentContinue(c: u8) bool { fn isReservedIdent(name: []const u8) bool { const reserved = [_][]const u8{ - "addrspace", "align", "allowzero", "and", "anyerror", "anyframe", "anyopaque", "anytype", - "asm", "async", "await", "bool", "break", "callconv", "catch", "comptime", - "const", "continue", "defer", "else", "enum", "errdefer", "error", "export", - "extern", "false", "fn", "for", "if", "inline", "isize", "linksection", - "noalias", "noreturn", "nosuspend", "null", "opaque", "or", "orelse", "packed", - "pub", "resume", "return", "struct", "suspend", "switch", "test", "threadlocal", - "true", "try", "type", "undefined", "union", "unreachable", "usize", "usingnamespace", - "var", "void", "volatile", "while", + "addrspace", "align", "allowzero", "and", "anyerror", "anyframe", "anyopaque", "anytype", + "asm", "async", "await", "bool", "break", "callconv", "catch", "comptime", + "const", "continue", "defer", "else", "enum", "errdefer", "error", "export", + "extern", "false", "fn", "for", "if", "inline", "isize", "linksection", + "noalias", "noreturn", "nosuspend", "null", "opaque", "or", "orelse", "packed", + "pub", "resume", "return", "struct", "suspend", "switch", "test", "threadlocal", + "true", "try", "undefined", "union", "unreachable", "usize", "usingnamespace", "var", + "void", "volatile", "while", }; for (reserved) |word| { if (std.mem.eql(u8, name, word)) return true; From 2fe14325e8bd6949b95a29f85e429d47b8d4cf47 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Sat, 30 May 2026 22:26:11 +0200 Subject: [PATCH 13/15] Use test_utils.createTestAllocator() for leak detection --- src/tests/binary_payload_tests.zig | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/tests/binary_payload_tests.zig b/src/tests/binary_payload_tests.zig index 6c02e2f..2a07903 100644 --- a/src/tests/binary_payload_tests.zig +++ b/src/tests/binary_payload_tests.zig @@ -309,7 +309,10 @@ test "v2.0 converter :: spec-level consumes inherits when operation omits consum } test "generated v3.0 :: uploadFile takes []const u8 requestBody and emits octet-stream Content-Type" { - const allocator = testing.allocator; + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + defer std.debug.assert(gpa.deinit() == .ok); + const file_contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, "generated/generated_v3.zig", allocator, .unlimited); defer allocator.free(file_contents); @@ -325,7 +328,10 @@ test "generated v3.0 :: uploadFile takes []const u8 requestBody and emits octet- } test "generated v3.0 :: addPet still uses JSON encoding for application/json body" { - const allocator = testing.allocator; + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + defer std.debug.assert(gpa.deinit() == .ok); + const file_contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, "generated/generated_v3.zig", allocator, .unlimited); defer allocator.free(file_contents); From 8eb10659326b402426622562073c3d12e8995e5d Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Sat, 30 May 2026 22:44:01 +0200 Subject: [PATCH 14/15] fix: normalize media-type parameters in body content-type handling Address PR #55 review (copilot-pull-request-reviewer): media types with parameters (e.g. "application/json; charset=utf-8") were compared verbatim, causing misclassification and non-deterministic JSON-first selection. - Add src/media_type.zig with baseMediaType/isJson/isJsonSuffix helpers and a deterministic selectBestJsonKey (exact JSON > +json suffix > lexicographic fallback), with unit tests. - classifyBody: strip parameters before classification so parameterized JSON bodies are JSON-encoded rather than emitted as raw []const u8. - generateFunctionBodyDirect: force Content-Type application/json for form bodies that fall back to JSON encoding (multipart/x-www-form-urlencoded not yet supported) so the header matches the actual payload. - OpenAPI v3.0/v3.1/v3.2 convertRequestBody: select best content-type key via normalized comparison; remove now-unused selectJsonSuffixKey helpers. - Swagger v2 selectConsumesMedia: compare on normalized base media type while returning the original string for header emission. - Add regression tests for parameterized media types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../converters/openapi31_converter.zig | 32 +------ .../converters/openapi32_converter.zig | 32 +------ .../converters/openapi_converter.zig | 32 +------ .../converters/swagger_converter.zig | 8 +- src/generators/unified/api_generator.zig | 17 +++- src/media_type.zig | 93 +++++++++++++++++++ src/tests.zig | 2 + src/tests/binary_payload_tests.zig | 34 +++++++ 8 files changed, 165 insertions(+), 85 deletions(-) create mode 100644 src/media_type.zig diff --git a/src/generators/converters/openapi31_converter.zig b/src/generators/converters/openapi31_converter.zig index ab2249f..db6cd11 100644 --- a/src/generators/converters/openapi31_converter.zig +++ b/src/generators/converters/openapi31_converter.zig @@ -14,6 +14,7 @@ const ParameterLocation = @import("../../models/common/document.zig").ParameterL const Response = @import("../../models/common/document.zig").Response; const Operation = @import("../../models/common/document.zig").Operation; const PathItem = @import("../../models/common/document.zig").PathItem; +const mime = @import("../../media_type.zig"); const OpenApi31Document = @import("../../models/v3.1/openapi.zig").OpenApi31Document; const Info31 = @import("../../models/v3.1/info.zig").Info; const Contact31 = @import("../../models/v3.1/info.zig").Contact; @@ -586,24 +587,10 @@ pub const OpenApi31Converter = struct { fn convertRequestBody(self: *OpenApi31Converter, requestBody: *const RequestBody31) !Parameter { var mut_request_body = requestBody.*; var schema: ?Schema = null; - var selected_key: ?[]const u8 = null; - if (mut_request_body.content.get("application/json")) |media_type| { - selected_key = "application/json"; - if (media_type.schema) |schema_or_ref| { - schema = try self.convertSchemaOrReference(schema_or_ref); - } - } else if (selectJsonSuffixKey31(mut_request_body.content)) |key| { - if (mut_request_body.content.get(key)) |media_type| { - selected_key = key; - if (media_type.schema) |schema_or_ref| { - schema = try self.convertSchemaOrReference(schema_or_ref); - } - } - } else if (mut_request_body.content.count() > 0) { - var it = mut_request_body.content.iterator(); - if (it.next()) |entry| { - selected_key = entry.key_ptr.*; - if (entry.value_ptr.schema) |schema_or_ref| { + const selected_key = mime.selectBestJsonKey(@TypeOf(mut_request_body.content), mut_request_body.content); + if (selected_key) |key| { + if (mut_request_body.content.get(key)) |media| { + if (media.schema) |schema_or_ref| { schema = try self.convertSchemaOrReference(schema_or_ref); } } @@ -624,15 +611,6 @@ pub const OpenApi31Converter = struct { }; } - fn selectJsonSuffixKey31(content: std.StringHashMap(@import("../../models/v3.1/media.zig").MediaType)) ?[]const u8 { - var it = content.iterator(); - while (it.next()) |entry| { - const key = entry.key_ptr.*; - if (std.mem.endsWith(u8, key, "+json")) return key; - } - return null; - } - fn convertParameters(self: *OpenApi31Converter, parameters: []const ParameterOrReference31) ![]Parameter { var converted_params = try self.allocator.alloc(Parameter, parameters.len); for (parameters, 0..) |param_ref, i| { diff --git a/src/generators/converters/openapi32_converter.zig b/src/generators/converters/openapi32_converter.zig index ffaad12..6c04ac7 100644 --- a/src/generators/converters/openapi32_converter.zig +++ b/src/generators/converters/openapi32_converter.zig @@ -14,6 +14,7 @@ const ParameterLocation = @import("../../models/common/document.zig").ParameterL const Response = @import("../../models/common/document.zig").Response; const Operation = @import("../../models/common/document.zig").Operation; const PathItem = @import("../../models/common/document.zig").PathItem; +const mime = @import("../../media_type.zig"); const OpenApi32Document = @import("../../models/v3.2/openapi.zig").OpenApi32Document; const Info32 = @import("../../models/v3.2/info.zig").Info; const Contact32 = @import("../../models/v3.2/info.zig").Contact; @@ -339,24 +340,10 @@ pub const OpenApi32Converter = struct { fn convertRequestBody(self: *OpenApi32Converter, requestBody: *const RequestBody32) !Parameter { var mut_request_body = requestBody.*; var schema: ?Schema = null; - var selected_key: ?[]const u8 = null; - if (mut_request_body.content.get("application/json")) |media_type| { - selected_key = "application/json"; - if (media_type.schema) |schema_or_ref| { - schema = try self.convertSchemaOrReference(schema_or_ref); - } - } else if (selectJsonSuffixKey32(mut_request_body.content)) |key| { - if (mut_request_body.content.get(key)) |media_type| { - selected_key = key; - if (media_type.schema) |schema_or_ref| { - schema = try self.convertSchemaOrReference(schema_or_ref); - } - } - } else if (mut_request_body.content.count() > 0) { - var it = mut_request_body.content.iterator(); - if (it.next()) |entry| { - selected_key = entry.key_ptr.*; - if (entry.value_ptr.schema) |schema_or_ref| { + const selected_key = mime.selectBestJsonKey(@TypeOf(mut_request_body.content), mut_request_body.content); + if (selected_key) |key| { + if (mut_request_body.content.get(key)) |media| { + if (media.schema) |schema_or_ref| { schema = try self.convertSchemaOrReference(schema_or_ref); } } @@ -377,15 +364,6 @@ pub const OpenApi32Converter = struct { }; } - fn selectJsonSuffixKey32(content: std.StringHashMap(@import("../../models/v3.2/media.zig").MediaType)) ?[]const u8 { - var it = content.iterator(); - while (it.next()) |entry| { - const key = entry.key_ptr.*; - if (std.mem.endsWith(u8, key, "+json")) return key; - } - return null; - } - fn convertParameters(self: *OpenApi32Converter, parameters: []const ParameterOrReference32) ![]Parameter { var converted_params = try self.allocator.alloc(Parameter, parameters.len); for (parameters, 0..) |param_ref, i| { diff --git a/src/generators/converters/openapi_converter.zig b/src/generators/converters/openapi_converter.zig index 01f57f2..aa065f0 100644 --- a/src/generators/converters/openapi_converter.zig +++ b/src/generators/converters/openapi_converter.zig @@ -14,6 +14,7 @@ const ParameterLocation = @import("../../models/common/document.zig").ParameterL const Response = @import("../../models/common/document.zig").Response; const Operation = @import("../../models/common/document.zig").Operation; const PathItem = @import("../../models/common/document.zig").PathItem; +const mime = @import("../../media_type.zig"); const OpenApiDocument = @import("../../models/v3.0/openapi.zig").OpenApiDocument; const Info3 = @import("../../models/v3.0/info.zig").Info; const Contact3 = @import("../../models/v3.0/info.zig").Contact; @@ -325,24 +326,10 @@ pub const OpenApiConverter = struct { fn convertRequestBody(self: *OpenApiConverter, requestBody: *const RequestBody3) !Parameter { var mut_request_body = requestBody.*; var schema: ?Schema = null; - var selected_key: ?[]const u8 = null; - if (mut_request_body.content.get("application/json")) |media_type| { - selected_key = "application/json"; - if (media_type.schema) |schema_or_ref| { - schema = try self.convertSchemaOrReference(schema_or_ref); - } - } else if (selectJsonSuffixKey(mut_request_body.content)) |key| { - if (mut_request_body.content.get(key)) |media_type| { - selected_key = key; - if (media_type.schema) |schema_or_ref| { - schema = try self.convertSchemaOrReference(schema_or_ref); - } - } - } else if (mut_request_body.content.count() > 0) { - var it = mut_request_body.content.iterator(); - if (it.next()) |entry| { - selected_key = entry.key_ptr.*; - if (entry.value_ptr.schema) |schema_or_ref| { + const selected_key = mime.selectBestJsonKey(@TypeOf(mut_request_body.content), mut_request_body.content); + if (selected_key) |key| { + if (mut_request_body.content.get(key)) |media| { + if (media.schema) |schema_or_ref| { schema = try self.convertSchemaOrReference(schema_or_ref); } } @@ -363,15 +350,6 @@ pub const OpenApiConverter = struct { }; } - fn selectJsonSuffixKey(content: std.StringHashMap(@import("../../models/v3.0/media.zig").MediaType)) ?[]const u8 { - var it = content.iterator(); - while (it.next()) |entry| { - const key = entry.key_ptr.*; - if (std.mem.endsWith(u8, key, "+json")) return key; - } - return null; - } - fn convertParameters(self: *OpenApiConverter, parameters: []const ParameterOrReference3) ![]Parameter { var converted_params = try self.allocator.alloc(Parameter, parameters.len); for (parameters, 0..) |param_ref, i| { diff --git a/src/generators/converters/swagger_converter.zig b/src/generators/converters/swagger_converter.zig index 83134f1..84fdd2b 100644 --- a/src/generators/converters/swagger_converter.zig +++ b/src/generators/converters/swagger_converter.zig @@ -14,6 +14,7 @@ const ParameterLocation = @import("../../models/common/document.zig").ParameterL const Response = @import("../../models/common/document.zig").Response; const Operation = @import("../../models/common/document.zig").Operation; const PathItem = @import("../../models/common/document.zig").PathItem; +const mime = @import("../../media_type.zig"); const SwaggerDocument = @import("../../models/v2.0/swagger.zig").SwaggerDocument; const Info2 = @import("../../models/v2.0/info.zig").Info; const Contact2 = @import("../../models/v2.0/info.zig").Contact; @@ -339,11 +340,14 @@ pub const SwaggerConverter = struct { fn selectConsumesMedia(list: []const []const u8) ?[]const u8 { if (list.len == 0) return null; + // Prefer JSON, then a "+json" suffix, comparing on the normalized base + // media type (parameters stripped, case-insensitive) while returning + // the original string so it can be emitted verbatim as a header. for (list) |m| { - if (std.mem.eql(u8, m, "application/json")) return m; + if (mime.isJson(m)) return m; } for (list) |m| { - if (std.mem.endsWith(u8, m, "+json")) return m; + if (mime.isJsonSuffix(m)) return m; } return list[0]; } diff --git a/src/generators/unified/api_generator.zig b/src/generators/unified/api_generator.zig index e60ead7..9b78700 100644 --- a/src/generators/unified/api_generator.zig +++ b/src/generators/unified/api_generator.zig @@ -5,6 +5,7 @@ const Operation = @import("../../models/common/document.zig").Operation; const Schema = @import("../../models/common/document.zig").Schema; const SchemaType = @import("../../models/common/document.zig").SchemaType; const Parameter = @import("../../models/common/document.zig").Parameter; +const media_type = @import("../../media_type.zig"); fn isIdentStart(c: u8) bool { return std.ascii.isAlphabetic(c) or c == '_'; @@ -23,7 +24,8 @@ fn endsWithIgnoreCase(haystack: []const u8, suffix: []const u8) bool { } fn classifyBody(content_type: ?[]const u8) BodyKind { - const ct = content_type orelse return .json; + const raw = content_type orelse return .json; + const ct = media_type.baseMediaType(raw); if (ct.len == 0) return .json; if (std.ascii.eqlIgnoreCase(ct, "application/json")) return .json; if (endsWithIgnoreCase(ct, "+json")) return .json; @@ -1440,7 +1442,13 @@ pub const UnifiedApiGenerator = struct { } const direct_kind = bodyKindFor(operation); const direct_body_param = findBodyParam(operation); - const direct_ct: []const u8 = if (direct_body_param) |p| (p.content_type orelse "application/json") else "application/json"; + // Form bodies fall back to JSON encoding (multipart/form-data and + // x-www-form-urlencoded are not yet supported), so the Content-Type + // header must reflect the actual JSON payload rather than the declared + // form media type. + const direct_ct: []const u8 = if (direct_kind == .form) + "application/json" + else if (direct_body_param) |p| (p.content_type orelse "application/json") else "application/json"; if (operation.parameters) |parameters| { for (parameters) |parameter| { @@ -1711,4 +1719,9 @@ test "BodyKind :: classifyBody routes media types correctly" { try t.expectEqual(BodyKind.text, classifyBody("text/csv")); try t.expectEqual(BodyKind.form, classifyBody("application/x-www-form-urlencoded")); try t.expectEqual(BodyKind.form, classifyBody("multipart/form-data")); + // Media types with parameters must be classified by their base type. + try t.expectEqual(BodyKind.json, classifyBody("application/json; charset=utf-8")); + try t.expectEqual(BodyKind.json, classifyBody("application/vnd.api+json; charset=utf-8")); + try t.expectEqual(BodyKind.text, classifyBody("text/plain; charset=utf-8")); + try t.expectEqual(BodyKind.form, classifyBody("multipart/form-data; boundary=abc")); } diff --git a/src/media_type.zig b/src/media_type.zig new file mode 100644 index 0000000..92883f3 --- /dev/null +++ b/src/media_type.zig @@ -0,0 +1,93 @@ +const std = @import("std"); + +/// Returns the base media type with any parameters (e.g. "; charset=utf-8") +/// and surrounding whitespace removed. The casing is preserved; callers should +/// compare case-insensitively. +pub fn baseMediaType(ct: []const u8) []const u8 { + var base = ct; + if (std.mem.indexOfScalar(u8, base, ';')) |i| base = base[0..i]; + return std.mem.trim(u8, base, " \t"); +} + +/// True when the media type (ignoring parameters, case-insensitive) is +/// exactly application/json. +pub fn isJson(ct: []const u8) bool { + return std.ascii.eqlIgnoreCase(baseMediaType(ct), "application/json"); +} + +/// True when the media type base ends with a structured "+json" suffix +/// (case-insensitive), e.g. application/vnd.api+json. +pub fn isJsonSuffix(ct: []const u8) bool { + const base = baseMediaType(ct); + if (base.len <= 5) return false; + return std.ascii.eqlIgnoreCase(base[base.len - 5 ..], "+json"); +} + +/// Selects the most appropriate content-type key from a media-type map, +/// preferring exact JSON, then a "+json" suffix, then any entry. Comparisons +/// ignore media-type parameters and casing, while the original key string is +/// returned. Ties within a tier are broken lexicographically so selection is +/// deterministic regardless of hash-map iteration order. +pub fn selectBestJsonKey(comptime MapType: type, content: MapType) ?[]const u8 { + var exact: ?[]const u8 = null; + var suffix: ?[]const u8 = null; + var fallback: ?[]const u8 = null; + var it = content.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + if (fallback == null or std.mem.lessThan(u8, key, fallback.?)) fallback = key; + if (isJson(key)) { + if (exact == null or std.mem.lessThan(u8, key, exact.?)) exact = key; + } else if (isJsonSuffix(key)) { + if (suffix == null or std.mem.lessThan(u8, key, suffix.?)) suffix = key; + } + } + return exact orelse suffix orelse fallback; +} + +test "baseMediaType strips parameters and whitespace" { + const t = std.testing; + try t.expectEqualStrings("application/json", baseMediaType("application/json")); + try t.expectEqualStrings("application/json", baseMediaType("application/json; charset=utf-8")); + try t.expectEqualStrings("application/json", baseMediaType(" application/json ; charset=utf-8")); + try t.expectEqualStrings("application/octet-stream", baseMediaType("application/octet-stream")); +} + +test "isJson and isJsonSuffix ignore parameters and casing" { + const t = std.testing; + try t.expect(isJson("application/json")); + try t.expect(isJson("Application/JSON; charset=utf-8")); + try t.expect(!isJson("application/octet-stream")); + try t.expect(isJsonSuffix("application/vnd.api+json")); + try t.expect(isJsonSuffix("application/vnd.api+JSON; charset=utf-8")); + try t.expect(!isJsonSuffix("+json")); + try t.expect(!isJsonSuffix("application/json")); +} + +test "selectBestJsonKey prefers exact then suffix then deterministic fallback" { + const t = std.testing; + const Map = std.StringHashMap(void); + var arena = std.heap.ArenaAllocator.init(t.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + { + var m = Map.init(a); + try m.put("application/octet-stream", {}); + try m.put("application/json; charset=utf-8", {}); + try m.put("application/vnd.api+json", {}); + try t.expectEqualStrings("application/json; charset=utf-8", selectBestJsonKey(Map, m).?); + } + { + var m = Map.init(a); + try m.put("application/octet-stream", {}); + try m.put("application/vnd.api+json", {}); + try t.expectEqualStrings("application/vnd.api+json", selectBestJsonKey(Map, m).?); + } + { + var m = Map.init(a); + try m.put("image/png", {}); + try m.put("application/octet-stream", {}); + try t.expectEqualStrings("application/octet-stream", selectBestJsonKey(Map, m).?); + } +} diff --git a/src/tests.zig b/src/tests.zig index 2260bf1..32250a7 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -9,6 +9,7 @@ const yaml_loader_tests = @import("tests/yaml_loader_tests.zig"); const resource_wrapper_tests = @import("tests/resource_wrapper_tests.zig"); const model_typing_tests = @import("tests/model_typing_tests.zig"); const binary_payload_tests = @import("tests/binary_payload_tests.zig"); +const media_type = @import("media_type.zig"); const generator = @import("generator.zig"); comptime { _ = openapi_v3_tests; @@ -22,5 +23,6 @@ comptime { _ = resource_wrapper_tests; _ = model_typing_tests; _ = binary_payload_tests; + _ = media_type; _ = generator; } diff --git a/src/tests/binary_payload_tests.zig b/src/tests/binary_payload_tests.zig index 2a07903..c8e26f4 100644 --- a/src/tests/binary_payload_tests.zig +++ b/src/tests/binary_payload_tests.zig @@ -308,6 +308,40 @@ test "v2.0 converter :: spec-level consumes inherits when operation omits consum try testing.expectEqualStrings("application/json", body.content_type.?); } +test "v3.0 converter :: JSON wins even when media type has parameters" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + defer std.debug.assert(gpa.deinit() == .ok); + + const spec = + \\{ + \\ "openapi": "3.0.0", + \\ "info": { "title": "T", "version": "1" }, + \\ "paths": { + \\ "/x": { + \\ "post": { + \\ "operationId": "doX", + \\ "requestBody": { + \\ "content": { + \\ "application/octet-stream": { "schema": { "type": "string" } }, + \\ "application/json; charset=utf-8": { "schema": { "type": "object" } } + \\ } + \\ }, + \\ "responses": { "200": { "description": "ok" } } + \\ } + \\ } + \\ } + \\} + ; + var unified = try parseAndConvertV3(allocator, spec); + defer unified.deinit(allocator); + + const op = findOperation(&unified, "/x", .post) orelse return error.OperationNotFound; + const body = findBodyParameter(op) orelse return error.BodyNotFound; + try testing.expect(body.content_type != null); + try testing.expectEqualStrings("application/json; charset=utf-8", body.content_type.?); +} + test "generated v3.0 :: uploadFile takes []const u8 requestBody and emits octet-stream Content-Type" { var gpa = test_utils.createTestAllocator(); const allocator = gpa.allocator(); From 7c5b78ad09987d815a257085b795fd1dac5b7fca Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Sat, 30 May 2026 23:05:30 +0200 Subject: [PATCH 15/15] fix: escape 'type' identifier and propagate JSON media type in requestRaw Address PR #55 review (copilot-pull-request-reviewer, 2 findings): - model_generator: re-add "type" to the reserved-identifier list. Although a bare 'type' is valid as a struct *field* name, the same appendIdentifier path also emits declaration/type names, where 'pub const type = struct {...}' is invalid Zig ("name shadows primitive 'type'"). Escaping as @"type" keeps all contexts valid; regenerated snapshots accordingly. - api_generator: requestRaw hard-coded Content-Type application/json for any payload. Add requestRawWithContentType helper (requestRaw delegates with application/json) and route declared non-application/json JSON media types (vendor +json, parameterized keys) through it from generateFunctionRaw so the emitted Content-Type matches Parameter.content_type. - Add a regression test asserting a vendor +json body propagates its media type to requestRawWithContentType. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- generated/generated_v2.zig | 8 +++-- generated/generated_v2_yaml.zig | 8 +++-- generated/generated_v3.zig | 8 +++-- generated/generated_v31.zig | 6 +++- generated/generated_v31_yaml.zig | 6 +++- generated/generated_v32.zig | 8 +++-- generated/generated_v3_yaml.zig | 8 +++-- src/generators/unified/api_generator.zig | 21 +++++++++--- src/generators/unified/model_generator.zig | 16 ++++----- src/tests/binary_payload_tests.zig | 38 ++++++++++++++++++++++ 10 files changed, 103 insertions(+), 24 deletions(-) diff --git a/generated/generated_v2.zig b/generated/generated_v2.zig index 58b1afa..42d7e50 100644 --- a/generated/generated_v2.zig +++ b/generated/generated_v2.zig @@ -44,7 +44,7 @@ pub const Order = struct { }; pub const ApiResponse = struct { - type: ?[]const u8 = null, + @"type": ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; @@ -171,10 +171,14 @@ fn appendQueryParam(writer: *std.Io.Writer, first_query: *bool, name: []const u8 } pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8) !RawResponse { + return requestRawWithContentType(client, method, url, payload, "application/json"); +} + +pub fn requestRawWithContentType(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8, content_type_value: []const u8) !RawResponse { const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const content_type: ?[]const u8 = if (payload != null) content_type_value else null; const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); diff --git a/generated/generated_v2_yaml.zig b/generated/generated_v2_yaml.zig index ef79546..593ade2 100644 --- a/generated/generated_v2_yaml.zig +++ b/generated/generated_v2_yaml.zig @@ -19,7 +19,7 @@ pub const Pet = struct { }; pub const ApiResponse = struct { - type: ?[]const u8 = null, + @"type": ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; @@ -171,10 +171,14 @@ fn appendQueryParam(writer: *std.Io.Writer, first_query: *bool, name: []const u8 } pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8) !RawResponse { + return requestRawWithContentType(client, method, url, payload, "application/json"); +} + +pub fn requestRawWithContentType(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8, content_type_value: []const u8) !RawResponse { const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const content_type: ?[]const u8 = if (payload != null) content_type_value else null; const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); diff --git a/generated/generated_v3.zig b/generated/generated_v3.zig index fb8a4a6..996f9a1 100644 --- a/generated/generated_v3.zig +++ b/generated/generated_v3.zig @@ -57,7 +57,7 @@ pub const User = struct { }; pub const ApiResponse = struct { - type: ?[]const u8 = null, + @"type": ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; @@ -184,10 +184,14 @@ fn appendQueryParam(writer: *std.Io.Writer, first_query: *bool, name: []const u8 } pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8) !RawResponse { + return requestRawWithContentType(client, method, url, payload, "application/json"); +} + +pub fn requestRawWithContentType(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8, content_type_value: []const u8) !RawResponse { const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const content_type: ?[]const u8 = if (payload != null) content_type_value else null; const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); diff --git a/generated/generated_v31.zig b/generated/generated_v31.zig index 2343a5b..c6a78ed 100644 --- a/generated/generated_v31.zig +++ b/generated/generated_v31.zig @@ -132,10 +132,14 @@ fn appendQueryParam(writer: *std.Io.Writer, first_query: *bool, name: []const u8 } pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8) !RawResponse { + return requestRawWithContentType(client, method, url, payload, "application/json"); +} + +pub fn requestRawWithContentType(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8, content_type_value: []const u8) !RawResponse { const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const content_type: ?[]const u8 = if (payload != null) content_type_value else null; const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); diff --git a/generated/generated_v31_yaml.zig b/generated/generated_v31_yaml.zig index 2343a5b..c6a78ed 100644 --- a/generated/generated_v31_yaml.zig +++ b/generated/generated_v31_yaml.zig @@ -132,10 +132,14 @@ fn appendQueryParam(writer: *std.Io.Writer, first_query: *bool, name: []const u8 } pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8) !RawResponse { + return requestRawWithContentType(client, method, url, payload, "application/json"); +} + +pub fn requestRawWithContentType(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8, content_type_value: []const u8) !RawResponse { const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const content_type: ?[]const u8 = if (payload != null) content_type_value else null; const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); diff --git a/generated/generated_v32.zig b/generated/generated_v32.zig index 8bc233c..74908fb 100644 --- a/generated/generated_v32.zig +++ b/generated/generated_v32.zig @@ -44,7 +44,7 @@ pub const Order = struct { }; pub const ApiResponse = struct { - type: ?[]const u8 = null, + @"type": ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; @@ -171,10 +171,14 @@ fn appendQueryParam(writer: *std.Io.Writer, first_query: *bool, name: []const u8 } pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8) !RawResponse { + return requestRawWithContentType(client, method, url, payload, "application/json"); +} + +pub fn requestRawWithContentType(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8, content_type_value: []const u8) !RawResponse { const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const content_type: ?[]const u8 = if (payload != null) content_type_value else null; const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); diff --git a/generated/generated_v3_yaml.zig b/generated/generated_v3_yaml.zig index fb8a4a6..996f9a1 100644 --- a/generated/generated_v3_yaml.zig +++ b/generated/generated_v3_yaml.zig @@ -57,7 +57,7 @@ pub const User = struct { }; pub const ApiResponse = struct { - type: ?[]const u8 = null, + @"type": ?[]const u8 = null, message: ?[]const u8 = null, code: ?i64 = null, }; @@ -184,10 +184,14 @@ fn appendQueryParam(writer: *std.Io.Writer, first_query: *bool, name: []const u8 } pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8) !RawResponse { + return requestRawWithContentType(client, method, url, payload, "application/json"); +} + +pub fn requestRawWithContentType(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8, content_type_value: []const u8) !RawResponse { const allocator = client.allocator; var headers = std.ArrayList(std.http.Header).empty; defer headers.deinit(allocator); - const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + const content_type: ?[]const u8 = if (payload != null) content_type_value else null; const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); defer if (auth_header) |value| allocator.free(value); diff --git a/src/generators/unified/api_generator.zig b/src/generators/unified/api_generator.zig index 9b78700..b380a2d 100644 --- a/src/generators/unified/api_generator.zig +++ b/src/generators/unified/api_generator.zig @@ -334,10 +334,14 @@ pub const UnifiedApiGenerator = struct { \\} \\ \\pub fn requestRaw(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8) !RawResponse { + \\ return requestRawWithContentType(client, method, url, payload, "application/json"); + \\} + \\ + \\pub fn requestRawWithContentType(client: *Client, method: std.http.Method, url: []const u8, payload: ?[]const u8, content_type_value: []const u8) !RawResponse { \\ const allocator = client.allocator; \\ var headers = std.ArrayList(std.http.Header).empty; \\ defer headers.deinit(allocator); - \\ const content_type: ?[]const u8 = if (payload != null) "application/json" else null; + \\ const content_type: ?[]const u8 = if (payload != null) content_type_value else null; \\ const auth_header = try appendClientHeaders(allocator, &headers, client, content_type, "application/json"); \\ defer if (auth_header) |value| allocator.free(value); \\ @@ -659,13 +663,22 @@ pub const UnifiedApiGenerator = struct { switch (kind) { .json => { + const json_ct: []const u8 = if (findBodyParam(operation)) |bp| (bp.content_type orelse "application/json") else "application/json"; try self.buffer.appendSlice(self.allocator, "\n var str: std.Io.Writer.Allocating = .init(allocator);\n"); try self.buffer.appendSlice(self.allocator, " defer str.deinit();\n"); try self.buffer.appendSlice(self.allocator, " try std.json.Stringify.value(requestBody, .{ .emit_null_optional_fields = false }, &str.writer);\n"); try self.buffer.appendSlice(self.allocator, " const payload: ?[]const u8 = str.written();\n"); - try self.buffer.appendSlice(self.allocator, "\n return requestRaw(client, std.http.Method."); - try self.buffer.appendSlice(self.allocator, method); - try self.buffer.appendSlice(self.allocator, ", uri_buf.written(), payload);\n"); + if (std.mem.eql(u8, json_ct, "application/json")) { + try self.buffer.appendSlice(self.allocator, "\n return requestRaw(client, std.http.Method."); + try self.buffer.appendSlice(self.allocator, method); + try self.buffer.appendSlice(self.allocator, ", uri_buf.written(), payload);\n"); + } else { + try self.buffer.appendSlice(self.allocator, "\n return requestRawWithContentType(client, std.http.Method."); + try self.buffer.appendSlice(self.allocator, method); + try self.buffer.appendSlice(self.allocator, ", uri_buf.written(), payload, \""); + try self.buffer.appendSlice(self.allocator, json_ct); + try self.buffer.appendSlice(self.allocator, "\");\n"); + } try self.buffer.appendSlice(self.allocator, "}\n\n"); return; }, diff --git a/src/generators/unified/model_generator.zig b/src/generators/unified/model_generator.zig index cc02621..44fb066 100644 --- a/src/generators/unified/model_generator.zig +++ b/src/generators/unified/model_generator.zig @@ -12,14 +12,14 @@ fn isIdentContinue(c: u8) bool { fn isReservedIdent(name: []const u8) bool { const reserved = [_][]const u8{ - "addrspace", "align", "allowzero", "and", "anyerror", "anyframe", "anyopaque", "anytype", - "asm", "async", "await", "bool", "break", "callconv", "catch", "comptime", - "const", "continue", "defer", "else", "enum", "errdefer", "error", "export", - "extern", "false", "fn", "for", "if", "inline", "isize", "linksection", - "noalias", "noreturn", "nosuspend", "null", "opaque", "or", "orelse", "packed", - "pub", "resume", "return", "struct", "suspend", "switch", "test", "threadlocal", - "true", "try", "undefined", "union", "unreachable", "usize", "usingnamespace", "var", - "void", "volatile", "while", + "addrspace", "align", "allowzero", "and", "anyerror", "anyframe", "anyopaque", "anytype", + "asm", "async", "await", "bool", "break", "callconv", "catch", "comptime", + "const", "continue", "defer", "else", "enum", "errdefer", "error", "export", + "extern", "false", "fn", "for", "if", "inline", "isize", "linksection", + "noalias", "noreturn", "nosuspend", "null", "opaque", "or", "orelse", "packed", + "pub", "resume", "return", "struct", "suspend", "switch", "test", "threadlocal", + "true", "try", "type", "undefined", "union", "unreachable", "usize", "usingnamespace", + "var", "void", "volatile", "while", }; for (reserved) |word| { if (std.mem.eql(u8, name, word)) return true; diff --git a/src/tests/binary_payload_tests.zig b/src/tests/binary_payload_tests.zig index c8e26f4..71e5892 100644 --- a/src/tests/binary_payload_tests.zig +++ b/src/tests/binary_payload_tests.zig @@ -11,6 +11,7 @@ const SwaggerConverter = @import("../generators/converters/swagger_converter.zig const OpenApiConverter = @import("../generators/converters/openapi_converter.zig").OpenApiConverter; const OpenApi31Converter = @import("../generators/converters/openapi31_converter.zig").OpenApi31Converter; const OpenApi32Converter = @import("../generators/converters/openapi32_converter.zig").OpenApi32Converter; +const UnifiedApiGenerator = @import("../generators/unified/api_generator.zig").UnifiedApiGenerator; fn loadOpenApiDocument(allocator: std.mem.Allocator, file_path: []const u8) !models.OpenApiDocument { const file_contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, file_path, allocator, .unlimited); @@ -342,6 +343,43 @@ test "v3.0 converter :: JSON wins even when media type has parameters" { try testing.expectEqualStrings("application/json; charset=utf-8", body.content_type.?); } +test "generated v3.0 :: vendor +json body propagates Content-Type to requestRaw" { + var gpa = test_utils.createTestAllocator(); + const allocator = gpa.allocator(); + defer std.debug.assert(gpa.deinit() == .ok); + + const spec = + \\{ + \\ "openapi": "3.0.0", + \\ "info": { "title": "T", "version": "1" }, + \\ "paths": { + \\ "/x": { + \\ "post": { + \\ "operationId": "doX", + \\ "requestBody": { + \\ "content": { + \\ "application/vnd.api+json": { "schema": { "type": "object" } } + \\ } + \\ }, + \\ "responses": { "200": { "description": "ok" } } + \\ } + \\ } + \\ } + \\} + ; + var unified = try parseAndConvertV3(allocator, spec); + defer unified.deinit(allocator); + + var generator = UnifiedApiGenerator.init(allocator, .{ .input_path = "fixture.json" }); + defer generator.deinit(); + const code = try generator.generate(unified); + defer allocator.free(code); + + // The non-application/json JSON media type must flow through to the request + // helper rather than being hard-coded to application/json. + try testing.expect(std.mem.indexOf(u8, code, "requestRawWithContentType(client, std.http.Method.POST, uri_buf.written(), payload, \"application/vnd.api+json\")") != null); +} + test "generated v3.0 :: uploadFile takes []const u8 requestBody and emits octet-stream Content-Type" { var gpa = test_utils.createTestAllocator(); const allocator = gpa.allocator();