Skip to content

Commit eba5871

Browse files
authored
Merge pull request #18 from bradcypert/claude/issue-10-20250718-0144
2 parents 39e33da + e8e05db commit eba5871

File tree

4 files changed

+131
-4
lines changed

4 files changed

+131
-4
lines changed

build.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub fn build(b: *std.Build) void {
44
const exe_name = b.option([]const u8, "exe_name", "Name of the executable") orelse "httpspec";
55
const dependencies = [_][]const u8{
66
"clap",
7+
"regex",
78
};
89

910
const target = b.standardTargetOptions(.{});

build.zig.zon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
.url = "git+https://github.com/Hejsil/zig-clap#cc5c6a5d71a317ed4b0ad776842d1d0655f72d0a",
4141
.hash = "clap-0.10.0-oBajB7jkAQAZ4cKLlzkeV9mDu2yGZvtN2QuOyfAfjBij",
4242
},
43+
.regex = .{
44+
.url = "git+https://github.com/tiehuis/zig-regex#8e38e11d45d3c45e06ed3e994e1eb2e62ed60637",
45+
.hash = "1220c65e96eb14c7de3e3a82bfc45a66e7ca72b80e0ae82d1b6b6e58b7d8c9e7b8",
46+
},
4347
},
4448
.paths = .{
4549
"build.zig",

src/httpfile/assertion_checker.zig

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ test "HttpParser supports contains and not_contains for headers" {
4343
}
4444
const std = @import("std");
4545
const http = std.http;
46+
const regex = @import("regex");
4647
const HttpParser = @import("./parser.zig");
4748
const Client = @import("./http_client.zig");
4849

@@ -54,6 +55,17 @@ fn extractHeaderName(key: []const u8) ![]const u8 {
5455
return key[start_quote + 1 .. end_quote];
5556
}
5657

58+
fn matchesRegex(text: []const u8, pattern: []const u8) bool {
59+
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
60+
defer arena.deinit();
61+
const allocator = arena.allocator();
62+
63+
const compiled_regex = regex.compile(allocator, pattern) catch return false;
64+
defer compiled_regex.deinit();
65+
66+
return compiled_regex.match(text);
67+
}
68+
5769
pub fn check(request: *HttpParser.HttpRequest, response: Client.HttpResponse) !void {
5870
const stderr = std.io.getStdErr().writer();
5971
for (request.assertions.items) |assertion| {
@@ -184,6 +196,58 @@ pub fn check(request: *HttpParser.HttpRequest, response: Client.HttpResponse) !v
184196
return error.InvalidAssertionKey;
185197
}
186198
},
199+
.matches_regex => {
200+
if (std.ascii.eqlIgnoreCase(assertion.key, "status")) {
201+
var status_buf: [3]u8 = undefined;
202+
const status_code = @intFromEnum(response.status.?);
203+
const status_str = std.fmt.bufPrint(&status_buf, "{}", .{status_code}) catch return error.StatusCodeFormat;
204+
if (!matchesRegex(status_str, assertion.value)) {
205+
stderr.print("[Fail] Expected status code to match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, status_str }) catch {};
206+
return error.StatusCodeNotMatchesRegex;
207+
}
208+
} else if (std.ascii.eqlIgnoreCase(assertion.key, "body")) {
209+
if (!matchesRegex(response.body, assertion.value)) {
210+
stderr.print("[Fail] Expected body content to match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, response.body }) catch {};
211+
return error.BodyContentNotMatchesRegex;
212+
}
213+
} else if (std.mem.startsWith(u8, assertion.key, "header[\"")) {
214+
const header_name = try extractHeaderName(assertion.key);
215+
const actual_value = response.headers.get(header_name);
216+
if (actual_value == null or !matchesRegex(actual_value.?, assertion.value)) {
217+
stderr.print("[Fail] Expected header \"{s}\" to match regex \"{s}\", got \"{s}\"\n", .{ header_name, assertion.value, actual_value orelse "null" }) catch {};
218+
return error.HeaderNotMatchesRegex;
219+
}
220+
} else {
221+
stderr.print("[Fail] Invalid assertion key for matches_regex: {s}\n", .{assertion.key}) catch {};
222+
return error.InvalidAssertionKey;
223+
}
224+
},
225+
.not_matches_regex => {
226+
if (std.ascii.eqlIgnoreCase(assertion.key, "status")) {
227+
var status_buf: [3]u8 = undefined;
228+
const status_code = @intFromEnum(response.status.?);
229+
const status_str = std.fmt.bufPrint(&status_buf, "{}", .{status_code}) catch return error.StatusCodeFormat;
230+
if (matchesRegex(status_str, assertion.value)) {
231+
stderr.print("[Fail] Expected status code to NOT match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, status_str }) catch {};
232+
return error.StatusCodeMatchesRegexButShouldnt;
233+
}
234+
} else if (std.ascii.eqlIgnoreCase(assertion.key, "body")) {
235+
if (matchesRegex(response.body, assertion.value)) {
236+
stderr.print("[Fail] Expected body content to NOT match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, response.body }) catch {};
237+
return error.BodyContentMatchesRegexButShouldnt;
238+
}
239+
} else if (std.mem.startsWith(u8, assertion.key, "header[\"")) {
240+
const header_name = try extractHeaderName(assertion.key);
241+
const actual_value = response.headers.get(header_name);
242+
if (actual_value != null and matchesRegex(actual_value.?, assertion.value)) {
243+
stderr.print("[Fail] Expected header \"{s}\" to NOT match regex \"{s}\", got \"{s}\"\n", .{ header_name, assertion.value, actual_value orelse "null" }) catch {};
244+
return error.HeaderMatchesRegexButShouldnt;
245+
}
246+
} else {
247+
stderr.print("[Fail] Invalid assertion key for not_matches_regex: {s}\n", .{assertion.key}) catch {};
248+
return error.InvalidAssertionKey;
249+
}
250+
},
187251
else => {},
188252
}
189253
}
@@ -340,3 +404,61 @@ test "HttpParser supports starts_with for status, body, and header" {
340404

341405
try check(&request, response);
342406
}
407+
408+
test "HttpParser supports matches_regex and not_matches_regex for status, body, and headers" {
409+
const allocator = std.testing.allocator;
410+
411+
var assertions = std.ArrayList(HttpParser.Assertion).init(allocator);
412+
defer assertions.deinit();
413+
414+
// Should pass: status matches regex for 2xx codes
415+
try assertions.append(HttpParser.Assertion{
416+
.key = "status",
417+
.value = "^2.*",
418+
.assertion_type = .matches_regex,
419+
});
420+
421+
// Should pass: body matches regex for JSON-like content
422+
try assertions.append(HttpParser.Assertion{
423+
.key = "body",
424+
.value = ".*success.*",
425+
.assertion_type = .matches_regex,
426+
});
427+
428+
// Should pass: header matches regex for application/* content types
429+
try assertions.append(HttpParser.Assertion{
430+
.key = "header[\"content-type\"]",
431+
.value = "application/.*",
432+
.assertion_type = .matches_regex,
433+
});
434+
435+
// Should pass: status does not match regex for error codes
436+
try assertions.append(HttpParser.Assertion{
437+
.key = "status",
438+
.value = "^[45].*",
439+
.assertion_type = .not_matches_regex,
440+
});
441+
442+
var request = HttpParser.HttpRequest{
443+
.method = .GET,
444+
.url = "https://api.example.com",
445+
.headers = std.ArrayList(http.Header).init(allocator),
446+
.assertions = assertions,
447+
.body = null,
448+
};
449+
450+
var response_headers = std.StringHashMap([]const u8).init(allocator);
451+
try response_headers.put("content-type", "application/json");
452+
defer response_headers.deinit();
453+
454+
const body = try allocator.dupe(u8, "Operation success completed");
455+
defer allocator.free(body);
456+
const response = Client.HttpResponse{
457+
.status = http.Status.ok,
458+
.headers = response_headers,
459+
.body = body,
460+
.allocator = allocator,
461+
};
462+
463+
try check(&request, response);
464+
}

src/httpfile/parser.zig

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ const AssertionType = enum {
1313
not_contains,
1414
starts_with,
1515
ends_with,
16-
// matches_regex, TODO: Soon.
17-
// not_matches_regex,
16+
matches_regex,
17+
not_matches_regex,
1818

1919
pub fn fromString(s: []const u8) ?AssertionType {
2020
if (std.ascii.eqlIgnoreCase(s, "==")) return .equal;
@@ -24,8 +24,8 @@ const AssertionType = enum {
2424
if (std.ascii.eqlIgnoreCase(s, "not_contains")) return .not_contains;
2525
if (std.ascii.eqlIgnoreCase(s, "starts_with")) return .starts_with;
2626
if (std.ascii.eqlIgnoreCase(s, "ends_with")) return .ends_with;
27-
// if (std.ascii.eqlIgnoreCase(s, "matches_regex")) return .matches_regex;
28-
// if (std.ascii.eqlIgnoreCase(s, "not_matches_regex")) return .not_matches_regex;
27+
if (std.ascii.eqlIgnoreCase(s, "matches_regex")) return .matches_regex;
28+
if (std.ascii.eqlIgnoreCase(s, "not_matches_regex")) return .not_matches_regex;
2929
return null;
3030
}
3131
};

0 commit comments

Comments
 (0)