An HTTP/1.1 server for zig
0

Configure Feed

Select the types of activity you want to include in your feed.

http.zig / src / testing.zig
22 kB 667 lines
1// Helpers for application developers to be able to mock 2// request and parse responses 3const std = @import("std"); 4const t = @import("t.zig"); 5const httpz = @import("httpz.zig"); 6 7const Conn = @import("worker.zig").HTTPConn; 8 9const ArrayList = std.ArrayList; 10const Allocator = std.mem.Allocator; 11 12pub fn init(config: httpz.Config) Testing { 13 const ctx = t.Context.init(config); 14 var conn = ctx.conn; 15 16 // Parse a basic request. This will put our conn.req_state into a valid state 17 // for creating a request. Application code can modify the request directly 18 // thereafter to change whatever properties they want. 19 var base_request: std.Io.Reader = .fixed("GET / HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); 20 while (true) { 21 const done = conn.req_state.parse(conn, &base_request) catch unreachable; 22 if (done) { 23 break; 24 } 25 } 26 27 const aa = conn.req_arena.allocator(); 28 29 const req = aa.create(httpz.Request) catch unreachable; 30 req.* = ctx.request(); 31 32 const res = aa.create(httpz.Response) catch unreachable; 33 res.* = ctx.response(); 34 35 return Testing{ 36 ._ctx = ctx, 37 .req = req, 38 .res = res, 39 .arena = aa, 40 .conn = ctx.conn, 41 }; 42} 43 44pub const Testing = struct { 45 _ctx: t.Context, 46 conn: *Conn, 47 req: *httpz.Request, 48 res: *httpz.Response, 49 arena: std.mem.Allocator, 50 parsed_response: ?Response = null, 51 52 pub const Response = struct { 53 status: u16, 54 raw: []const u8, 55 body: []const u8, 56 allocator: std.mem.Allocator, 57 headers: std.StringHashMap([]const u8), 58 59 pub fn expectHeader(self: Response, name: []const u8, expected: ?[]const u8) !void { 60 if (expected) |e| { 61 try t.expectString(e, self.headers.get(name).?); 62 } else { 63 try t.expectEqual(null, self.headers.get(name)); 64 } 65 } 66 67 pub fn expectJson(self: Response, expected: anytype) !void { 68 if (self.headers.get("Content-Type")) |ct| { 69 try t.expectString("application/json; charset=UTF-8", ct); 70 } else { 71 return error.NoContentTypeHeader; 72 } 73 74 var jc = JsonComparer.init(t.allocator); 75 defer jc.deinit(); 76 const diffs = try jc.compare(expected, self.body); 77 if (diffs.items.len == 0) { 78 return; 79 } 80 81 for (diffs.items, 0..) |diff, i| { 82 std.debug.print("\n==Difference #{d}==\n", .{i + 1}); 83 std.debug.print("{s}: {s} {s} {s}\n", .{ diff.path, diff.a, diff.err, diff.b }); 84 std.debug.print("Actual:\n{s}\n", .{jc.pretty_actual orelse self.body}); 85 } 86 return error.JsonNotEqual; 87 } 88 89 pub fn deinit(self: *Response) void { 90 self.headers.deinit(); 91 self.allocator.free(self.raw); 92 } 93 }; 94 95 pub fn deinit(self: *Testing) void { 96 self._ctx.deinit(); 97 } 98 99 pub fn url(self: *Testing, u: []const u8) void { 100 self.req.url = httpz.Url.parse(u); 101 } 102 103 pub fn param(self: *Testing, name: []const u8, value: []const u8) void { 104 // This is ugly, but the Param structure is optimized for how the router 105 // works, so we don't have a clean API for setting 1 key=value pair. We'll 106 // just dig into the internals instead 107 var p = self.req.params; 108 p.names[p.len] = name; 109 p.values[p.len] = value; 110 p.len += 1; 111 } 112 113 pub fn query(self: *Testing, name: []const u8, value: []const u8) void { 114 const req = self.req; 115 req.qs_read = true; 116 req.qs.add(name, value); 117 118 const encoded_name = escapeString(self.arena, name) catch unreachable; 119 const encoded_value = escapeString(self.arena, value) catch unreachable; 120 const kv = std.fmt.allocPrint(self.arena, "{s}={s}", .{ encoded_name, encoded_value }) catch unreachable; 121 122 const q = req.url.query; 123 if (q.len == 0) { 124 req.url.query = kv; 125 } else { 126 req.url.query = std.fmt.allocPrint(self.arena, "{s}&{s}", .{ q, kv }) catch unreachable; 127 } 128 } 129 130 pub fn header(self: *Testing, name: []const u8, value: []const u8) void { 131 const lower = self.arena.alloc(u8, name.len) catch unreachable; 132 _ = std.ascii.lowerString(lower, name); 133 self.req.headers.add(lower, value); 134 } 135 136 pub fn body(self: *Testing, bd: []const u8) void { 137 const mutable = self.arena.dupe(u8, bd) catch unreachable; 138 self.req.body_buffer = .{ .type = .static, .data = mutable }; 139 self.req.body_len = bd.len; 140 } 141 142 pub fn json(self: *Testing, value: anytype) void { 143 const json_writer = std.json.fmt(value, .{}); 144 var aw: std.Io.Writer.Allocating = .init(self.arena); 145 json_writer.format(&aw.writer) catch unreachable; 146 147 self.req.body_buffer = .{ .type = .static, .data = aw.written() }; 148 self.req.body_len = aw.written().len; 149 } 150 151 pub fn form(self: *Testing, data: anytype) void { 152 var arr: ArrayList(u8) = .empty; 153 154 inline for (@typeInfo(@TypeOf(data)).@"struct".fields) |field| { 155 const name = escapeString(self.arena, field.name) catch unreachable; 156 const value = escapeString(self.arena, @field(data, field.name)) catch unreachable; 157 arr.appendSlice(self.arena, name) catch unreachable; 158 arr.append(self.arena, '=') catch unreachable; 159 arr.appendSlice(self.arena, value) catch unreachable; 160 arr.append(self.arena, '&') catch unreachable; 161 } 162 163 const items = arr.items; 164 if (items.len == 0) { 165 return; 166 } 167 168 // strip out the last & 169 const bd = items[0 .. items.len - 1]; 170 self.req.body_buffer = .{ .type = .static, .data = bd }; 171 self.req.body_len = bd.len; 172 } 173 174 pub fn expectGzip(self: *Testing) !void { 175 var res = try self.parseResponse(); 176 if (res.headers.get("Content-Encoding")) |ce| { 177 try t.expectString("gzip", ce); 178 } else { 179 return error.NoContentEncoding; 180 } 181 182 var fbs = std.io.fixedBufferStream(res.body); 183 var uncompressed = std.ArrayList(u8).init(self.arena); 184 try std.compress.gzip.decompress(fbs.reader(), uncompressed.writer()); 185 186 res.body = uncompressed.items; 187 self.parsed_response = res; 188 } 189 190 pub fn expectStatus(self: *const Testing, expected: u16) !void { 191 try t.expectEqual(expected, self.res.status); 192 } 193 194 pub fn expectStatusCode(self: *const Testing, expected: std.http.Status) !void { 195 const code = @intFromEnum(expected); 196 try t.expectEqual(code, self.res.status); 197 } 198 199 pub fn expectBody(self: *Testing, expected: []const u8) !void { 200 const pr = try self.parseResponse(); 201 try t.expectString(expected, pr.body); 202 } 203 204 pub fn expectJson(self: *Testing, expected: anytype) !void { 205 const pr = try self.parseResponse(); 206 try pr.expectJson(expected); 207 } 208 209 pub fn expectHeader(self: *Testing, name: []const u8, expected: ?[]const u8) !void { 210 const pr = try self.parseResponse(); 211 return pr.expectHeader(name, expected); 212 } 213 214 pub fn expectHeaderCount(self: *Testing, expected: u32) !void { 215 const pr = try self.parseResponse(); 216 try t.expectEqual(expected, pr.headers.count()); 217 } 218 219 pub fn getJson(self: *Testing) !std.json.Value { 220 const pr = try self.parseResponse(); 221 return try std.json.parseFromSliceLeaky(std.json.Value, self.arena, pr.body, .{}); 222 } 223 224 pub fn getBody(self: *Testing) ![]const u8 { 225 const pr = try self.parseResponse(); 226 return pr.body; 227 } 228 229 pub fn parseResponse(self: *Testing) !Response { 230 if (self.parsed_response) |r| return r; 231 232 try self.res.write(); 233 self._ctx.close(); 234 235 const data = try self._ctx.read(self.arena); 236 const pr = try parseWithAllocator(self.arena, data.items); 237 self.parsed_response = pr; 238 return pr; 239 } 240}; 241 242pub fn parse(data: []u8) !Testing.Response { 243 return parseWithAllocator(t.allocator, data); 244} 245 246pub fn parseWithAllocator(allocator: Allocator, data: []u8) !Testing.Response { 247 // data won't outlive this function, we want our Response to take ownership 248 // of the full body, since it needs to reference parts of it. 249 const raw = allocator.dupe(u8, data) catch unreachable; 250 251 var status: u16 = 0; 252 var header_length: usize = 0; 253 var headers = std.StringHashMap([]const u8).init(allocator); 254 255 var it = std.mem.splitSequence(u8, raw, "\r\n"); 256 if (it.next()) |line| { 257 header_length = line.len + 2; 258 status = try std.fmt.parseInt(u16, line[9..12], 10); 259 } else { 260 return error.InvalidResponseLine; 261 } 262 263 while (it.next()) |line| { 264 header_length += line.len + 2; 265 if (line.len == 0) break; 266 if (std.mem.indexOfScalar(u8, line, ':')) |index| { 267 // +2 to strip out the leading space 268 headers.put(line[0..index], line[index + 2 ..]) catch unreachable; 269 } else { 270 return error.InvalidHeader; 271 } 272 } 273 274 var body_length = raw.len - header_length; 275 if (headers.get("Transfer-Encoding")) |te| { 276 if (std.mem.eql(u8, te, "chunked")) { 277 body_length = decodeChunkedEncoding(raw[header_length..], data[header_length..]); 278 } 279 } 280 281 return .{ 282 .raw = raw, 283 .status = status, 284 .headers = headers, 285 .allocator = allocator, 286 .body = raw[header_length .. header_length + body_length], 287 }; 288} 289 290/// Waits until a TCP port is accepting connections (e.g. after starting a server in another thread). 291/// Tries up to 100 times with 20ms sleep between attempts. 292pub fn waitForPort(port: u16) !void { 293 const address = std.Io.net.IpAddress.parse("127.0.0.1", port) catch unreachable; 294 for (0..100) |_| { 295 if (address.connect(t.io, .{ .mode = .stream })) |stream| { 296 stream.close(t.io); 297 return; 298 } else |err| { 299 if (err != error.ConnectionRefused) return err; 300 try t.io.sleep(.fromMilliseconds(20), .awake); 301 } 302 } 303 return error.ConnectionRefused; 304} 305 306fn decodeChunkedEncoding(full_dest: []u8, full_src: []u8) usize { 307 var src = full_src; 308 var dest = full_dest; 309 var length: usize = 0; 310 const clean_trailer = std.mem.endsWith(u8, src, "\r\n0\r\n\r\n"); 311 312 while (true) { 313 const nl = std.mem.indexOfScalar(u8, src, '\r') orelse unreachable; 314 const chunk_length = std.fmt.parseInt(u32, src[0..nl], 16) catch unreachable; 315 if (chunk_length == 0) { 316 if (src[1] == '\r' and src[2] == '\n' and src[3] == '\r' and src[4] == '\n') { 317 break; 318 } 319 continue; 320 } 321 322 @memcpy(dest[0..chunk_length], src[nl + 2 .. nl + 2 + chunk_length]); 323 length += chunk_length; 324 325 dest = dest[chunk_length..]; 326 const next_start = nl + 4 + chunk_length; 327 328 if (src.len < next_start and clean_trailer == false) { 329 // This should not happen, but...When http.zig writes a chunked 330 // response, the trailing empty chunk only gets written deep in the 331 // library. When calling a handler directly from a test, that part 332 // of the library isn't executed, and thus an invalid chunked encoded 333 // body is written (it's missing that empty trailing chunk). 334 break; 335 } 336 src = src[next_start..]; 337 } 338 return length; 339} 340 341fn escapeString(allocator: Allocator, input: []const u8) ![]const u8 { 342 var outsize: usize = 0; 343 for (input) |c| { 344 outsize += if (isUnreserved(c)) @as(usize, 1) else 3; 345 } 346 var output = try allocator.alloc(u8, outsize); 347 var outptr: usize = 0; 348 349 for (input) |c| { 350 if (isUnreserved(c)) { 351 output[outptr] = c; 352 outptr += 1; 353 } else { 354 var buf: [2]u8 = undefined; 355 _ = std.fmt.bufPrint(&buf, "{X:0>2}", .{c}) catch unreachable; 356 357 output[outptr + 0] = '%'; 358 output[outptr + 1] = buf[0]; 359 output[outptr + 2] = buf[1]; 360 outptr += 3; 361 } 362 } 363 return output; 364} 365 366fn isUnreserved(c: u8) bool { 367 return switch (c) { 368 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, 369 else => false, 370 }; 371} 372 373const JsonComparer = struct { 374 _arena: std.heap.ArenaAllocator, 375 pretty_actual: ?[]const u8 = null, 376 377 const Diff = struct { 378 err: []const u8, 379 path: []const u8, 380 a: []const u8, 381 b: []const u8, 382 }; 383 384 fn init(allocator: Allocator) JsonComparer { 385 return .{ 386 ._arena = std.heap.ArenaAllocator.init(allocator), 387 }; 388 } 389 390 fn deinit(self: JsonComparer) void { 391 self._arena.deinit(); 392 } 393 394 // We compare by getting the string representation of a and b 395 // and then parsing it into a std.json.ValueTree, which we can compare 396 // Either a or b might already be serialized JSON string. 397 fn compare(self: *JsonComparer, a: anytype, b: anytype) !ArrayList(Diff) { 398 const allocator = self._arena.allocator(); 399 400 var a_bytes: []const u8 = undefined; 401 if (comptime isString(@TypeOf(a))) { 402 a_bytes = a; 403 } else { 404 a_bytes = try self.stringify(a); 405 } 406 407 var b_bytes: []const u8 = undefined; 408 if (comptime isString(@TypeOf(b))) { 409 b_bytes = b; 410 } else { 411 b_bytes = try self.stringify(b); 412 } 413 414 const a_value = try std.json.parseFromSliceLeaky(std.json.Value, allocator, a_bytes, .{}); 415 const b_value = try std.json.parseFromSliceLeaky(std.json.Value, allocator, b_bytes, .{}); 416 417 const json_writer = std.json.fmt(b_value, .{ .whitespace = .indent_2 }); 418 var aw: std.Io.Writer.Allocating = .init(allocator); 419 try json_writer.format(&aw.writer); 420 self.pretty_actual = aw.written(); 421 422 var diffs: ArrayList(Diff) = .empty; 423 var path: ArrayList([]const u8) = .empty; 424 try self.compareValue(a_value, b_value, &diffs, &path); 425 return diffs; 426 } 427 428 fn compareValue(self: *JsonComparer, a: std.json.Value, b: std.json.Value, diffs: *ArrayList(Diff), path: *ArrayList([]const u8)) !void { 429 const allocator = self._arena.allocator(); 430 431 if (!std.mem.eql(u8, @tagName(a), @tagName(b))) { 432 diffs.append(allocator, self.diff("types don't match", path, @tagName(a), @tagName(b))) catch unreachable; 433 return; 434 } 435 436 switch (a) { 437 .null => {}, 438 .bool => { 439 if (a.bool != b.bool) { 440 diffs.append(allocator, self.diff("not equal", path, self.format(a.bool), self.format(b.bool))) catch unreachable; 441 } 442 }, 443 .integer => { 444 if (a.integer != b.integer) { 445 diffs.append(allocator, self.diff("not equal", path, self.format(a.integer), self.format(b.integer))) catch unreachable; 446 } 447 }, 448 .float => { 449 if (a.float != b.float) { 450 diffs.append(allocator, self.diff("not equal", path, self.format(a.float), self.format(b.float))) catch unreachable; 451 } 452 }, 453 .number_string => { 454 if (!std.mem.eql(u8, a.number_string, b.number_string)) { 455 diffs.append(allocator, self.diff("not equal", path, a.number_string, b.number_string)) catch unreachable; 456 } 457 }, 458 .string => { 459 if (!std.mem.eql(u8, a.string, b.string)) { 460 diffs.append(allocator, self.diff("not equal", path, a.string, b.string)) catch unreachable; 461 } 462 }, 463 .array => { 464 const a_len = a.array.items.len; 465 const b_len = b.array.items.len; 466 if (a_len != b_len) { 467 diffs.append(allocator, self.diff("array length", path, self.format(a_len), self.format(b_len))) catch unreachable; 468 return; 469 } 470 for (a.array.items, b.array.items, 0..) |a_item, b_item, i| { 471 try path.append(allocator, try std.fmt.allocPrint(allocator, "{d}", .{i})); 472 try self.compareValue(a_item, b_item, diffs, path); 473 _ = path.pop(); 474 } 475 }, 476 .object => { 477 var it = a.object.iterator(); 478 while (it.next()) |entry| { 479 const key = entry.key_ptr.*; 480 try path.append(allocator, key); 481 if (b.object.get(key)) |b_item| { 482 try self.compareValue(entry.value_ptr.*, b_item, diffs, path); 483 } else { 484 diffs.append(allocator, self.diff("field missing", path, key, "")) catch unreachable; 485 } 486 _ = path.pop(); 487 } 488 }, 489 } 490 } 491 492 fn diff(self: *JsonComparer, err: []const u8, path: *ArrayList([]const u8), a_rep: []const u8, b_rep: []const u8) Diff { 493 const full_path = std.mem.join(self._arena.allocator(), ".", path.items) catch unreachable; 494 return .{ 495 .a = a_rep, 496 .b = b_rep, 497 .err = err, 498 .path = full_path, 499 }; 500 } 501 502 fn stringify(self: *JsonComparer, value: anytype) ![]const u8 { 503 var aw: std.Io.Writer.Allocating = .init(self._arena.allocator()); 504 const json_writer = std.json.fmt(value, .{}); 505 try json_writer.format(&aw.writer); 506 return aw.written(); 507 } 508 509 fn format(self: *JsonComparer, value: anytype) []const u8 { 510 return std.fmt.allocPrint(self._arena.allocator(), "{}", .{value}) catch unreachable; 511 } 512}; 513 514fn isString(comptime T: type) bool { 515 switch (@typeInfo(T)) { 516 .pointer => |ptr| switch (ptr.size) { 517 .slice => return ptr.child == u8, 518 .one => switch (@typeInfo(ptr.child)) { 519 .array => |arr| return arr.child == u8, 520 else => return false, 521 }, 522 else => return false, 523 }, 524 .array => return false, 525 else => return false, 526 } 527} 528 529test "testing: params" { 530 var ht = init(.{}); 531 defer ht.deinit(); 532 533 ht.param("id", "over9000"); 534 try t.expectString("over9000", ht.req.params.get("id").?); 535 try t.expectEqual(null, ht.req.params.get("other")); 536} 537 538test "testing: query" { 539 var ht = init(.{}); 540 defer ht.deinit(); 541 542 ht.query("search", "t:ea"); 543 ht.query("category", "447"); 544 545 const query = try ht.req.query(); 546 try t.expectString("t:ea", query.get("search").?); 547 try t.expectString("447", query.get("category").?); 548 try t.expectString("search=t%3Aea&category=447", ht.req.url.query); 549 try t.expectEqual(null, query.get("other")); 550} 551 552test "testing: empty query" { 553 var ht = init(.{}); 554 defer ht.deinit(); 555 556 const query = try ht.req.query(); 557 try t.expectEqual(0, query.len); 558} 559 560test "testing: query via url" { 561 var ht = init(.{}); 562 defer ht.deinit(); 563 ht.url("/hello?duncan=idaho"); 564 565 const query = try ht.req.query(); 566 try t.expectString("idaho", query.get("duncan").?); 567} 568 569test "testing: header" { 570 var ht = init(.{}); 571 defer ht.deinit(); 572 573 ht.header("Search", "tea"); 574 ht.header("Category", "447"); 575 576 try t.expectString("tea", ht.req.headers.get("search").?); 577 try t.expectString("447", ht.req.headers.get("category").?); 578 try t.expectEqual(null, ht.req.headers.get("other")); 579} 580 581test "testing: body" { 582 var ht = init(.{}); 583 defer ht.deinit(); 584 585 ht.body("the body"); 586 try t.expectString("the body", ht.req.body().?); 587} 588 589test "testing: json" { 590 var ht = init(.{}); 591 defer ht.deinit(); 592 593 ht.json(.{ .over = 9000 }); 594 try t.expectString("{\"over\":9000}", ht.req.body().?); 595} 596 597test "testing: form" { 598 var ht = init(.{ .request = .{ .max_form_count = 2 } }); 599 defer ht.deinit(); 600 601 ht.form(.{ .over = "(9000)", .hello = "wo rld" }); 602 const fd = try ht.req.formData(); 603 try t.expectString("(9000)", fd.get("over").?); 604 try t.expectString("wo rld", fd.get("hello").?); 605} 606 607test "testing: expectBody empty" { 608 var ht = init(.{}); 609 defer ht.deinit(); 610 try ht.expectStatus(200); 611 try ht.expectBody(""); 612 try ht.expectHeaderCount(1); 613 try ht.expectHeader("Content-Length", "0"); 614} 615 616test "testing: expectBody" { 617 var ht = init(.{}); 618 defer ht.deinit(); 619 ht.res.status = 404; 620 ht.res.body = "nope"; 621 622 try ht.expectStatus(404); 623 try ht.expectBody("nope"); 624} 625 626test "testing: expectJson" { 627 var ht = init(.{}); 628 defer ht.deinit(); 629 ht.res.status = 201; 630 try ht.res.json(.{ .tea = "keemun", .price = .{ .amount = 4990, .discount = 0.1 } }, .{}); 631 632 try ht.expectStatus(201); 633 try ht.expectJson(.{ .price = .{ .discount = 0.1, .amount = 4990 }, .tea = "keemun" }); 634} 635 636test "testing: getJson" { 637 var ht = init(.{}); 638 defer ht.deinit(); 639 640 ht.res.status = 201; 641 try ht.res.json(.{ .tea = "silver needle" }, .{}); 642 643 try ht.expectStatus(201); 644 const json = try ht.getJson(); 645 try t.expectString("silver needle", json.object.get("tea").?.string); 646} 647 648test "testing: parseResponse" { 649 var ht = init(.{}); 650 defer ht.deinit(); 651 ht.res.status = 201; 652 try ht.res.json(.{ .tea = 33 }, .{}); 653 654 try ht.expectStatus(201); 655 const res = try ht.parseResponse(); 656 try t.expectEqual(201, res.status); 657 try t.expectEqual(2, res.headers.count()); 658} 659 660test "testing: expectStatusCode" { 661 var ht = init(.{}); 662 defer ht.deinit(); 663 664 ht.res.status = 201; 665 666 try ht.expectStatusCode(.created); 667}