Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub fn build(b: *std.Build) void {
const exe_name = b.option([]const u8, "exe_name", "Name of the executable") orelse "httpspec";
const dependencies = [_][]const u8{
"clap",
"regex",
};

const target = b.standardTargetOptions(.{});
Expand Down
4 changes: 4 additions & 0 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
.url = "git+https://github.com/Hejsil/zig-clap#cc5c6a5d71a317ed4b0ad776842d1d0655f72d0a",
.hash = "clap-0.10.0-oBajB7jkAQAZ4cKLlzkeV9mDu2yGZvtN2QuOyfAfjBij",
},
.regex = .{
.url = "git+https://github.com/tiehuis/zig-regex#8e38e11d45d3c45e06ed3e994e1eb2e62ed60637",
.hash = "1220c65e96eb14c7de3e3a82bfc45a66e7ca72b80e0ae82d1b6b6e58b7d8c9e7b8",
},
},
.paths = .{
"build.zig",
Expand Down
122 changes: 122 additions & 0 deletions src/httpfile/assertion_checker.zig
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ test "HttpParser supports contains and not_contains for headers" {
}
const std = @import("std");
const http = std.http;
const regex = @import("regex");
const HttpParser = @import("./parser.zig");
const Client = @import("./http_client.zig");

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

fn matchesRegex(text: []const u8, pattern: []const u8) bool {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();

const compiled_regex = regex.compile(allocator, pattern) catch return false;
defer compiled_regex.deinit();

return compiled_regex.match(text);
}

pub fn check(request: *HttpParser.HttpRequest, response: Client.HttpResponse) !void {
const stderr = std.io.getStdErr().writer();
for (request.assertions.items) |assertion| {
Expand Down Expand Up @@ -184,6 +196,58 @@ pub fn check(request: *HttpParser.HttpRequest, response: Client.HttpResponse) !v
return error.InvalidAssertionKey;
}
},
.matches_regex => {
if (std.ascii.eqlIgnoreCase(assertion.key, "status")) {
var status_buf: [3]u8 = undefined;
const status_code = @intFromEnum(response.status.?);
const status_str = std.fmt.bufPrint(&status_buf, "{}", .{status_code}) catch return error.StatusCodeFormat;
if (!matchesRegex(status_str, assertion.value)) {
stderr.print("[Fail] Expected status code to match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, status_str }) catch {};
return error.StatusCodeNotMatchesRegex;
}
} else if (std.ascii.eqlIgnoreCase(assertion.key, "body")) {
if (!matchesRegex(response.body, assertion.value)) {
stderr.print("[Fail] Expected body content to match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, response.body }) catch {};
return error.BodyContentNotMatchesRegex;
}
} else if (std.mem.startsWith(u8, assertion.key, "header[\"")) {
const header_name = try extractHeaderName(assertion.key);
const actual_value = response.headers.get(header_name);
if (actual_value == null or !matchesRegex(actual_value.?, assertion.value)) {
stderr.print("[Fail] Expected header \"{s}\" to match regex \"{s}\", got \"{s}\"\n", .{ header_name, assertion.value, actual_value orelse "null" }) catch {};
return error.HeaderNotMatchesRegex;
}
} else {
stderr.print("[Fail] Invalid assertion key for matches_regex: {s}\n", .{assertion.key}) catch {};
return error.InvalidAssertionKey;
}
},
.not_matches_regex => {
if (std.ascii.eqlIgnoreCase(assertion.key, "status")) {
var status_buf: [3]u8 = undefined;
const status_code = @intFromEnum(response.status.?);
const status_str = std.fmt.bufPrint(&status_buf, "{}", .{status_code}) catch return error.StatusCodeFormat;
if (matchesRegex(status_str, assertion.value)) {
stderr.print("[Fail] Expected status code to NOT match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, status_str }) catch {};
return error.StatusCodeMatchesRegexButShouldnt;
}
} else if (std.ascii.eqlIgnoreCase(assertion.key, "body")) {
if (matchesRegex(response.body, assertion.value)) {
stderr.print("[Fail] Expected body content to NOT match regex \"{s}\", got \"{s}\"\n", .{ assertion.value, response.body }) catch {};
return error.BodyContentMatchesRegexButShouldnt;
}
} else if (std.mem.startsWith(u8, assertion.key, "header[\"")) {
const header_name = try extractHeaderName(assertion.key);
const actual_value = response.headers.get(header_name);
if (actual_value != null and matchesRegex(actual_value.?, assertion.value)) {
stderr.print("[Fail] Expected header \"{s}\" to NOT match regex \"{s}\", got \"{s}\"\n", .{ header_name, assertion.value, actual_value orelse "null" }) catch {};
return error.HeaderMatchesRegexButShouldnt;
}
} else {
stderr.print("[Fail] Invalid assertion key for not_matches_regex: {s}\n", .{assertion.key}) catch {};
return error.InvalidAssertionKey;
}
},
else => {},
}
}
Expand Down Expand Up @@ -340,3 +404,61 @@ test "HttpParser supports starts_with for status, body, and header" {

try check(&request, response);
}

test "HttpParser supports matches_regex and not_matches_regex for status, body, and headers" {
const allocator = std.testing.allocator;

var assertions = std.ArrayList(HttpParser.Assertion).init(allocator);
defer assertions.deinit();

// Should pass: status matches regex for 2xx codes
try assertions.append(HttpParser.Assertion{
.key = "status",
.value = "^2.*",
.assertion_type = .matches_regex,
});

// Should pass: body matches regex for JSON-like content
try assertions.append(HttpParser.Assertion{
.key = "body",
.value = ".*success.*",
.assertion_type = .matches_regex,
});

// Should pass: header matches regex for application/* content types
try assertions.append(HttpParser.Assertion{
.key = "header[\"content-type\"]",
.value = "application/.*",
.assertion_type = .matches_regex,
});

// Should pass: status does not match regex for error codes
try assertions.append(HttpParser.Assertion{
.key = "status",
.value = "^[45].*",
.assertion_type = .not_matches_regex,
});

var request = HttpParser.HttpRequest{
.method = .GET,
.url = "https://api.example.com",
.headers = std.ArrayList(http.Header).init(allocator),
.assertions = assertions,
.body = null,
};

var response_headers = std.StringHashMap([]const u8).init(allocator);
try response_headers.put("content-type", "application/json");
defer response_headers.deinit();

const body = try allocator.dupe(u8, "Operation success completed");
defer allocator.free(body);
const response = Client.HttpResponse{
.status = http.Status.ok,
.headers = response_headers,
.body = body,
.allocator = allocator,
};

try check(&request, response);
}
8 changes: 4 additions & 4 deletions src/httpfile/parser.zig
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ const AssertionType = enum {
not_contains,
starts_with,
ends_with,
// matches_regex, TODO: Soon.
// not_matches_regex,
matches_regex,
not_matches_regex,

pub fn fromString(s: []const u8) ?AssertionType {
if (std.ascii.eqlIgnoreCase(s, "==")) return .equal;
Expand All @@ -24,8 +24,8 @@ const AssertionType = enum {
if (std.ascii.eqlIgnoreCase(s, "not_contains")) return .not_contains;
if (std.ascii.eqlIgnoreCase(s, "starts_with")) return .starts_with;
if (std.ascii.eqlIgnoreCase(s, "ends_with")) return .ends_with;
// if (std.ascii.eqlIgnoreCase(s, "matches_regex")) return .matches_regex;
// if (std.ascii.eqlIgnoreCase(s, "not_matches_regex")) return .not_matches_regex;
if (std.ascii.eqlIgnoreCase(s, "matches_regex")) return .matches_regex;
if (std.ascii.eqlIgnoreCase(s, "not_matches_regex")) return .not_matches_regex;
return null;
}
};
Expand Down
Loading