Skip to content

Commit dff249a

Browse files
Implement matches_regex and not_matches_regex assertion types
- Add matches_regex and not_matches_regex to AssertionType enum in parser.zig - Add string parsing support for both regex types in fromString method - Implement basic regex matcher with support for: - .* (any characters) - ^ (start anchor) and $ (end anchor) - . (any single character) - [abc] (character classes) - Literal text matching - Add assertion checking logic for both regex types in assertion_checker.zig - Add comprehensive test case for regex functionality Co-authored-by: Brad <bradcypert@users.noreply.github.com>
1 parent 4f695b4 commit dff249a

File tree

2 files changed

+210
-4
lines changed

2 files changed

+210
-4
lines changed

src/httpfile/assertion_checker.zig

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,102 @@ fn extractHeaderName(key: []const u8) ![]const u8 {
5454
return key[start_quote + 1 .. end_quote];
5555
}
5656

57+
fn matchesRegex(text: []const u8, pattern: []const u8) bool {
58+
if (pattern.len == 0) return text.len == 0;
59+
60+
// Handle anchors
61+
const starts_with_anchor = pattern[0] == '^';
62+
const ends_with_anchor = pattern.len > 0 and pattern[pattern.len - 1] == '$';
63+
64+
var actual_pattern = pattern;
65+
if (starts_with_anchor) actual_pattern = pattern[1..];
66+
if (ends_with_anchor and actual_pattern.len > 0) actual_pattern = actual_pattern[0..actual_pattern.len - 1];
67+
68+
if (starts_with_anchor and ends_with_anchor) {
69+
return matchesRegexAt(text, actual_pattern, 0) == text.len;
70+
} else if (starts_with_anchor) {
71+
return matchesRegexAt(text, actual_pattern, 0) != null;
72+
} else if (ends_with_anchor) {
73+
var i: usize = 0;
74+
while (i <= text.len) : (i += 1) {
75+
if (matchesRegexAt(text[i..], actual_pattern, 0)) |end_pos| {
76+
if (i + end_pos == text.len) return true;
77+
}
78+
}
79+
return false;
80+
} else {
81+
var i: usize = 0;
82+
while (i <= text.len) : (i += 1) {
83+
if (matchesRegexAt(text[i..], actual_pattern, 0) != null) return true;
84+
}
85+
return false;
86+
}
87+
}
88+
89+
fn matchesRegexAt(text: []const u8, pattern: []const u8, text_pos: usize) ?usize {
90+
var p_pos: usize = 0;
91+
var t_pos = text_pos;
92+
93+
while (p_pos < pattern.len and t_pos < text.len) {
94+
if (p_pos + 1 < pattern.len and pattern[p_pos + 1] == '*') {
95+
// Handle .* or character*
96+
const match_char = pattern[p_pos];
97+
p_pos += 2; // Skip char and *
98+
99+
// Try matching zero occurrences first
100+
if (matchesRegexAt(text, pattern[p_pos..], t_pos)) |end_pos| {
101+
return t_pos + end_pos;
102+
}
103+
104+
// Try matching one or more occurrences
105+
while (t_pos < text.len) {
106+
if (match_char == '.' or text[t_pos] == match_char) {
107+
t_pos += 1;
108+
if (matchesRegexAt(text, pattern[p_pos..], t_pos)) |end_pos| {
109+
return t_pos + end_pos;
110+
}
111+
} else {
112+
break;
113+
}
114+
}
115+
return null;
116+
} else if (pattern[p_pos] == '.') {
117+
// Match any single character
118+
t_pos += 1;
119+
p_pos += 1;
120+
} else if (pattern[p_pos] == '[') {
121+
// Character class
122+
const close_bracket = std.mem.indexOfScalarPos(u8, pattern, p_pos + 1, ']') orelse return null;
123+
const char_class = pattern[p_pos + 1..close_bracket];
124+
var matched = false;
125+
for (char_class) |c| {
126+
if (text[t_pos] == c) {
127+
matched = true;
128+
break;
129+
}
130+
}
131+
if (!matched) return null;
132+
t_pos += 1;
133+
p_pos = close_bracket + 1;
134+
} else {
135+
// Literal character match
136+
if (text[t_pos] != pattern[p_pos]) return null;
137+
t_pos += 1;
138+
p_pos += 1;
139+
}
140+
}
141+
142+
// Handle remaining .* patterns at end
143+
while (p_pos + 1 < pattern.len and pattern[p_pos + 1] == '*') {
144+
p_pos += 2;
145+
}
146+
147+
if (p_pos == pattern.len) {
148+
return t_pos - text_pos;
149+
}
150+
return null;
151+
}
152+
57153
pub fn check(request: *HttpParser.HttpRequest, response: Client.HttpResponse) !void {
58154
const stderr = std.io.getStdErr().writer();
59155
for (request.assertions.items) |assertion| {
@@ -184,6 +280,58 @@ pub fn check(request: *HttpParser.HttpRequest, response: Client.HttpResponse) !v
184280
return error.InvalidAssertionKey;
185281
}
186282
},
283+
.matches_regex => {
284+
if (std.ascii.eqlIgnoreCase(assertion.key, "status")) {
285+
var status_buf: [3]u8 = undefined;
286+
const status_code = @intFromEnum(response.status.?);
287+
const status_str = std.fmt.bufPrint(&status_buf, "{}", .{status_code}) catch return error.StatusCodeFormat;
288+
if (!matchesRegex(status_str, assertion.value)) {
289+
stderr.print("[Fail] Expected status code to match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, status_str }) catch {};
290+
return error.StatusCodeNotMatchesRegex;
291+
}
292+
} else if (std.ascii.eqlIgnoreCase(assertion.key, "body")) {
293+
if (!matchesRegex(response.body, assertion.value)) {
294+
stderr.print("[Fail] Expected body content to match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, response.body }) catch {};
295+
return error.BodyContentNotMatchesRegex;
296+
}
297+
} else if (std.mem.startsWith(u8, assertion.key, "header[\"")) {
298+
const header_name = try extractHeaderName(assertion.key);
299+
const actual_value = response.headers.get(header_name);
300+
if (actual_value == null or !matchesRegex(actual_value.?, assertion.value)) {
301+
stderr.print("[Fail] Expected header \"{s}\" to match regex \"{s}\", got \"{s}\"\n", .{ header_name, assertion.value, actual_value orelse "null" }) catch {};
302+
return error.HeaderNotMatchesRegex;
303+
}
304+
} else {
305+
stderr.print("[Fail] Invalid assertion key for matches_regex: {s}\n", .{assertion.key}) catch {};
306+
return error.InvalidAssertionKey;
307+
}
308+
},
309+
.not_matches_regex => {
310+
if (std.ascii.eqlIgnoreCase(assertion.key, "status")) {
311+
var status_buf: [3]u8 = undefined;
312+
const status_code = @intFromEnum(response.status.?);
313+
const status_str = std.fmt.bufPrint(&status_buf, "{}", .{status_code}) catch return error.StatusCodeFormat;
314+
if (matchesRegex(status_str, assertion.value)) {
315+
stderr.print("[Fail] Expected status code to NOT match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, status_str }) catch {};
316+
return error.StatusCodeMatchesRegexButShouldnt;
317+
}
318+
} else if (std.ascii.eqlIgnoreCase(assertion.key, "body")) {
319+
if (matchesRegex(response.body, assertion.value)) {
320+
stderr.print("[Fail] Expected body content to NOT match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, response.body }) catch {};
321+
return error.BodyContentMatchesRegexButShouldnt;
322+
}
323+
} else if (std.mem.startsWith(u8, assertion.key, "header[\"")) {
324+
const header_name = try extractHeaderName(assertion.key);
325+
const actual_value = response.headers.get(header_name);
326+
if (actual_value != null and matchesRegex(actual_value.?, assertion.value)) {
327+
stderr.print("[Fail] Expected header \"{s}\" to NOT match regex \"{s}\", got \"{s}\"\n", .{ header_name, assertion.value, actual_value orelse "null" }) catch {};
328+
return error.HeaderMatchesRegexButShouldnt;
329+
}
330+
} else {
331+
stderr.print("[Fail] Invalid assertion key for not_matches_regex: {s}\n", .{assertion.key}) catch {};
332+
return error.InvalidAssertionKey;
333+
}
334+
},
187335
else => {},
188336
}
189337
}
@@ -340,3 +488,61 @@ test "HttpParser supports starts_with for status, body, and header" {
340488

341489
try check(&request, response);
342490
}
491+
492+
test "HttpParser supports matches_regex and not_matches_regex for status, body, and headers" {
493+
const allocator = std.testing.allocator;
494+
495+
var assertions = std.ArrayList(HttpParser.Assertion).init(allocator);
496+
defer assertions.deinit();
497+
498+
// Should pass: status matches regex for 2xx codes
499+
try assertions.append(HttpParser.Assertion{
500+
.key = "status",
501+
.value = "^2.*",
502+
.assertion_type = .matches_regex,
503+
});
504+
505+
// Should pass: body matches regex for JSON-like content
506+
try assertions.append(HttpParser.Assertion{
507+
.key = "body",
508+
.value = ".*success.*",
509+
.assertion_type = .matches_regex,
510+
});
511+
512+
// Should pass: header matches regex for application/* content types
513+
try assertions.append(HttpParser.Assertion{
514+
.key = "header[\"content-type\"]",
515+
.value = "application/.*",
516+
.assertion_type = .matches_regex,
517+
});
518+
519+
// Should pass: status does not match regex for error codes
520+
try assertions.append(HttpParser.Assertion{
521+
.key = "status",
522+
.value = "^[45].*",
523+
.assertion_type = .not_matches_regex,
524+
});
525+
526+
var request = HttpParser.HttpRequest{
527+
.method = .GET,
528+
.url = "https://api.example.com",
529+
.headers = std.ArrayList(http.Header).init(allocator),
530+
.assertions = assertions,
531+
.body = null,
532+
};
533+
534+
var response_headers = std.StringHashMap([]const u8).init(allocator);
535+
try response_headers.put("content-type", "application/json");
536+
defer response_headers.deinit();
537+
538+
const body = try allocator.dupe(u8, "Operation success completed");
539+
defer allocator.free(body);
540+
const response = Client.HttpResponse{
541+
.status = http.Status.ok,
542+
.headers = response_headers,
543+
.body = body,
544+
.allocator = allocator,
545+
};
546+
547+
try check(&request, response);
548+
}

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)