An HTTP client opens a network connection, sends a request, receives a response, and writes the response body.
An HTTP client opens a network connection, sends a request, receives a response, and writes the response body.
We will build a small program named get.
get example.com /It will send this HTTP request:
GET / HTTP/1.1
Host: example.com
Connection: closeHere is the first version.
const std = @import("std");
pub fn main() !void {
var args = std.process.args();
_ = args.next();
const host = args.next() orelse {
std.debug.print("missing host\n", .{});
return;
};
const path = args.next() orelse "/";
const address_list = try std.net.getAddressList(
std.heap.page_allocator,
host,
80,
);
defer address_list.deinit();
const address = address_list.addrs[0];
const stream = try std.net.tcpConnectToAddress(address);
defer stream.close();
var writer = stream.writer();
var reader = stream.reader();
try writer.print(
"GET {s} HTTP/1.1\r\n" ++
"Host: {s}\r\n" ++
"Connection: close\r\n" ++
"\r\n",
.{ path, host },
);
var out = std.io.getStdOut().writer();
var buffer: [4096]u8 = undefined;
while (true) {
const n = try reader.read(&buffer);
if (n == 0) break;
try out.writeAll(buffer[0..n]);
}
}Run it:
zig run main.zig -- example.com /The program prints the whole HTTP response: status line, headers, blank line, and body.
The first new operation is name lookup.
const address_list = try std.net.getAddressList(
std.heap.page_allocator,
host,
80,
);
defer address_list.deinit();A host name can resolve to more than one network address. getAddressList returns a list. The list owns memory, so it must be freed with deinit.
This program chooses the first address.
const address = address_list.addrs[0];A stronger program would try each address until one connection succeeds.
The connection is opened with:
const stream = try std.net.tcpConnectToAddress(address);
defer stream.close();A TCP stream is a byte stream. It has no concept of HTTP requests or responses. HTTP is just text and bytes sent over the stream.
The request is written with print.
try writer.print(
"GET {s} HTTP/1.1\r\n" ++
"Host: {s}\r\n" ++
"Connection: close\r\n" ++
"\r\n",
.{ path, host },
);HTTP uses \r\n at the end of each header line. The empty line ends the header section.
Then the program copies bytes from the network stream to standard output.
while (true) {
const n = try reader.read(&buffer);
if (n == 0) break;
try out.writeAll(buffer[0..n]);
}This is the same shape as the file copier. The source is now a socket instead of a file. The destination is standard output.
Network programs should be strict about ownership. In this example:
| Value | Resource | Cleanup |
|---|---|---|
address_list | allocated address list | deinit |
stream | TCP connection | close |
buffer | stack memory | none |
This client only speaks plain HTTP on port 80. It does not handle HTTPS. It does not parse headers. It does not follow redirects. It does not decompress content. Those are separate layers.
A small improvement is to put request writing in a function.
fn sendRequest(writer: anytype, host: []const u8, path: []const u8) !void {
try writer.print(
"GET {s} HTTP/1.1\r\n" ++
"Host: {s}\r\n" ++
"Connection: close\r\n" ++
"\r\n",
.{ path, host },
);
}Now main can show the main sequence more clearly.
const std = @import("std");
fn sendRequest(writer: anytype, host: []const u8, path: []const u8) !void {
try writer.print(
"GET {s} HTTP/1.1\r\n" ++
"Host: {s}\r\n" ++
"Connection: close\r\n" ++
"\r\n",
.{ path, host },
);
}
pub fn main() !void {
var args = std.process.args();
_ = args.next();
const host = args.next() orelse return error.MissingHost;
const path = args.next() orelse "/";
const address_list = try std.net.getAddressList(
std.heap.page_allocator,
host,
80,
);
defer address_list.deinit();
const stream = try std.net.tcpConnectToAddress(address_list.addrs[0]);
defer stream.close();
var writer = stream.writer();
var reader = stream.reader();
var out = std.io.getStdOut().writer();
try sendRequest(writer, host, path);
var buffer: [4096]u8 = undefined;
while (true) {
const n = try reader.read(&buffer);
if (n == 0) break;
try out.writeAll(buffer[0..n]);
}
}The type of writer is written as anytype in sendRequest.
fn sendRequest(writer: anytype, host: []const u8, path: []const u8) !voidThis makes the function generic. Any value that has a compatible print method can be passed. The compiler checks the use at compile time.
This is common Zig style. Do not build an interface first. Write the function against the operations it needs.
Exercise 20-16. Print only the response headers.
Exercise 20-17. Print only the response body.
Exercise 20-18. Try every address returned by getAddressList.
Exercise 20-19. Add a --port option.
Exercise 20-20. Reject paths that do not begin with /.