This post remains relevant with zig 0.17.0
If you’re still on zig 0.15.2, here’s my older post on making HTTP requests.
Concepts to understand before we make HTTP requests #
The Fetch #
client.fetch(options) is the request. It connects, sends the request, follows redirects, receives the response, and returns once everything has arrived. The options parameter is a struct with defaults for nearly every field, so a minimal call is short:
const result = try client.fetch(.{
.location = .{ .url = "https://postman-echo.com/get" },
.response_writer = &body.writer,
});
A few defaults are worth knowing:
.locationtakes.urlor.uri..methoddefaults to GET if you don’t provide a payload, POST if you provide one..response_writeris optional, leave it out and the body is read and discarded, which is fine when you only care about the status.
result is a FetchResult, which holds status.
The Body #
fetch() doesn’t return the body, it writes it into any *std.Io.Writer. For those “give me the whole body in memory” situations use std.Io.Writer.Allocting, a writer backed by a growable heap buffer.
var body: std.Io.Writer.Allocating = .init(allocator);
defer body.deinit();
_ = try client.fetch(.{ ..., .response_writer = &body.writer });
const text = body.written():
const owned = try body.ToOwnedSlice();
Use written() when you plan to use the body inside the same function, otherwise use toOwnedSlice().
The Headers #
fetch() has two header fields:
.headerscontrols the six headers the client manages itself:host,authorization,user_agent,connection,accept_encoding,content_type. Each one is.default,.omit, or.{override = "value" }..extra_headersis a slice of name/value pairs sent verbatim in addition to everything else.
The client always sends its own User-Agent: zig/0.16.0 (std.http). Put your User-Agent in .extra_headers and the request carries two of them. To replace it:
.headers = .{ .user_agent = . { .override = "definitepotato/0.0.1" } },
Rule of thumb is if it is one of those six, use .headers, anything else goes in .extra_headers.
Sending a body #
.payload is a plain ?[]const u8. The fetch() sets Content-Length from it, but:
- It never sets
Content-Type. The default is to send none at all. - It does not follow redirects when there is a payload.
The fail #
try client.fetch(...) only catches failures where:
- No response arrived.
- An unparseable URL (
error.InvalidFormat). - A host that does not resolve (
error.NoAddressReturned). - A refused connection.
- A TLS failure.
Connection pooling #
A std.http.Client keeps a pool of open connections. When the next request goes to a host it already talked to, it reuses the connection and skips the TCP and TLS handshakes.
Let’s write code #
Method 1: GET #
const std = @import("std");
pub fn main() !void {
var threaded: std.Io.Threaded = .init(std.heap.page_allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var client: std.http.Client = .{ .allocator = allocator, .io = io };
defer client.deinit();
var body: std.Io.Writer.Allocating = .init(allocator);
defer body.deinit();
const result = try client.fetch(.{
.location = .{ .url = "https://postman-echo.com/get?key=value" },
.method = .GET,
.response_writer = &body.writer,
});
std.debug.print("Status: {d} {t}\n", .{ @intFromEnum(result.status), result.status });
std.debug.print("Body: {s}\n", .{body.written()});
}
Method 2: POST and PUT #
const std = @import("std");
pub fn main() !void {
var threaded: std.Io.Threaded = .init(std.heap.page_allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var client: std.http.Client = .{ .allocator = allocator, .io = io };
defer client.deinit();
const payload =
\\{
\\ "id": 1,
\\ "username": "definitepotato",
\\ "avatar": "definitepotato.png"
\\}
;
const json_headers: std.http.Client.Request.Headers = .{
.content_type = .{ .override = "application/json" },
};
{
var body: std.Io.Writer.Allocating = .init(allocator);
defer body.deinit();
const result = try client.fetch(.{
.location = .{ .url = "https://jsonplaceholder.typicode.com/posts" },
.method = .POST,
.payload = payload,
.headers = json_headers,
.response_writer = &body.writer,
});
std.debug.print("POST: {t}\n{s}\n\n", .{ result.status, body.written() });
}
{
var body: std.Io.Writer.Allocating = .init(allocator);
defer body.deinit();
const result = try client.fetch(.{
.location = .{ .url = "https://postman-echo.com/put" },
.method = .PUT,
.payload = payload,
.headers = json_headers,
.response_writer = &body.writer,
});
std.debug.print("PUT: {t}\n{s}\n", .{ result.status, body.written() });
}
}
Method 3: Reusable HTTP client (GET, POST, PUT, DELETE) #
const std = @import("std");
const Response = struct {
status: std.http.Status,
body: []u8,
allocator: std.mem.Allocator,
fn deinit(self: Response) void {
self.allocator.free(self.body);
}
};
const RequestOptions = struct {
method: std.http.Method = .GET,
payload: ?[]const u8 = null,
extra_headers: []const std.http.Header = &.{},
};
const user_agent = "definitepotato/0.0.1";
fn request(client: *std.http.Client, url: []const u8, options: RequestOptions) !Response {
const allocator = client.allocator;
var body: std.Io.Writer.Allocating = .init(allocator);
defer body.deinit();
const result = try client.fetch(.{
.location = .{ .url = url },
.method = options.method,
.payload = options.payload,
.response_writer = &body.writer,
.headers = .{
.user_agent = .{ .override = user_agent },
.content_type = if (options.payload != null)
.{ .override = "application/json" }
else
.default,
},
.extra_headers = options.extra_headers,
});
if (result.status.class() != .success) {
std.debug.print("{s} {s}: {d} {t}\n{s}\n", .{
@tagName(options.method), url,
@intFromEnum(result.status), result.status,
body.written(),
});
return error.HttpRequestFailed;
}
return .{
.status = result.status,
.body = try body.toOwnedSlice(),
.allocator = allocator,
};
}
pub fn main() !void {
var threaded: std.Io.Threaded = .init(std.heap.page_allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var client: std.http.Client = .{ .allocator = allocator, .io = io };
defer client.deinit();
const accept_json = &[_]std.http.Header{
.{ .name = "Accept", .value = "application/json" },
};
const payload =
\\{"id": 1, "username": "definitepotato", "avatar": "definitepotato.png"}
;
{
const res = try request(&client, "https://postman-echo.com/get?key1=val1&key2=val2", .{
.extra_headers = accept_json,
});
defer res.deinit();
std.debug.print("GET: {t}\n{s}\n\n", .{ res.status, res.body });
}
{
const res = try request(&client, "https://jsonplaceholder.typicode.com/posts", .{
.method = .POST,
.payload = payload,
.extra_headers = accept_json,
});
defer res.deinit();
std.debug.print("POST: {t}\n{s}\n\n", .{ res.status, res.body });
}
{
const res = try request(&client, "https://postman-echo.com/put", .{
.method = .PUT,
.payload = payload,
.extra_headers = accept_json,
});
defer res.deinit();
std.debug.print("PUT: {t}\n{s}\n\n", .{ res.status, res.body });
}
{
const res = try request(&client, "https://postman-echo.com/delete", .{ .method = .DELETE });
defer res.deinit();
std.debug.print("DELETE: {t}\n{s}\n\n", .{ res.status, res.body });
}
if (request(&client, "https://postman-echo.com/status/404", .{})) |res| {
res.deinit();
} else |err| {
std.debug.print("-> {s}\n", .{@errorName(err)});
}
}
Method 4: Serializing JSON #
const std = @import("std");
const NewPost = struct {
userId: u32,
title: []const u8,
body: []const u8,
};
const Post = struct {
id: u32,
userId: u32,
title: []const u8,
body: []const u8,
};
pub fn main() !void {
var threaded: std.Io.Threaded = .init(std.heap.page_allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var client: std.http.Client = .{ .allocator = allocator, .io = io };
defer client.deinit();
const new_post: NewPost = .{
.userId = 7,
.title = "Zig and HTTP",
.body = "Now with 0.16 and std.Io.",
};
const payload = try std.json.Stringify.valueAlloc(allocator, new_post, .{});
defer allocator.free(payload);
std.debug.print("sending: {s}\n", .{payload});
var response_body: std.Io.Writer.Allocating = .init(allocator);
defer response_body.deinit();
const result = try client.fetch(.{
.location = .{ .url = "https://jsonplaceholder.typicode.com/posts" },
.method = .POST,
.payload = payload,
.headers = .{ .content_type = .{ .override = "application/json" } },
.response_writer = &response_body.writer,
});
if (result.status.class() != .success) return error.HttpRequestFailed;
std.debug.print("received: {s}\n", .{response_body.written()});
const parsed = try std.json.parseFromSlice(
Post,
allocator,
response_body.written(),
.{ .ignore_unknown_fields = true },
);
defer parsed.deinit();
const post = parsed.value;
std.debug.print("\nserver assigned id {d} to \"{s}\" by user {d}\n", .{
post.id, post.title, post.userId,
});
}