forked from
karlseguin.tngl.sh/http.zig
An HTTP/1.1 server for zig
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}