lib/http/src/client/operation/model.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_phase = @import("alloc_phase");
3 const response = @import("../root.zig").response;
4
5 pub const default_host_bytes: usize = 256;
6 pub const default_target_bytes: usize = 4096 + 1 + 4096;
7 pub const default_plain_read_bytes: usize = 8192;
8 pub const default_plain_write_bytes: usize = 8192;
9
10 pub const Limits = struct {
11 operation_count: usize,
12 host_bytes_per_operation: usize,
13 target_bytes_per_operation: usize,
14 plain_read_bytes_per_operation: usize,
15 plain_write_bytes_per_operation: usize,
16 };
17
18 pub const Capacity = struct {
19 operation_count: usize,
20 host_bytes_per_operation: usize,
21 target_bytes_per_operation: usize,
22 plain_read_bytes_per_operation: usize,
23 plain_write_bytes_per_operation: usize,
24 host_bytes: usize,
25 target_bytes: usize,
26 plain_read_bytes: usize,
27 plain_write_bytes: usize,
28 storage_bytes: usize,
29
30 pub fn derive(limits: Limits) error{CapacityOverflow}!Capacity {
31 const host_bytes = try alloc_phase.capacity.mul(
32 usize,
33 limits.operation_count,
34 limits.host_bytes_per_operation,
35 );
36 const target_bytes = try alloc_phase.capacity.mul(
37 usize,
38 limits.operation_count,
39 limits.target_bytes_per_operation,
40 );
41 const plain_read_bytes = try alloc_phase.capacity.mul(
42 usize,
43 limits.operation_count,
44 limits.plain_read_bytes_per_operation,
45 );
46 const plain_write_bytes = try alloc_phase.capacity.mul(
47 usize,
48 limits.operation_count,
49 limits.plain_write_bytes_per_operation,
50 );
51 const host_and_target_bytes = try alloc_phase.capacity.add(
52 usize,
53 host_bytes,
54 target_bytes,
55 );
56 const plain_bytes = try alloc_phase.capacity.add(
57 usize,
58 plain_read_bytes,
59 plain_write_bytes,
60 );
61 const storage_bytes = try alloc_phase.capacity.add(
62 usize,
63 host_and_target_bytes,
64 plain_bytes,
65 );
66 std.debug.assert(host_bytes <= storage_bytes);
67 std.debug.assert(target_bytes <= storage_bytes);
68 std.debug.assert(plain_read_bytes <= storage_bytes);
69 std.debug.assert(plain_write_bytes <= storage_bytes);
70 return .{
71 .operation_count = limits.operation_count,
72 .host_bytes_per_operation = limits.host_bytes_per_operation,
73 .target_bytes_per_operation = limits.target_bytes_per_operation,
74 .plain_read_bytes_per_operation = limits.plain_read_bytes_per_operation,
75 .plain_write_bytes_per_operation = limits.plain_write_bytes_per_operation,
76 .host_bytes = host_bytes,
77 .target_bytes = target_bytes,
78 .plain_read_bytes = plain_read_bytes,
79 .plain_write_bytes = plain_write_bytes,
80 .storage_bytes = storage_bytes,
81 };
82 }
83 };
84
85 pub const Scratch = struct {
86 host: []u8,
87 target: []u8,
88 plain_read: []u8,
89 plain_write: []u8,
90 };
91
92 pub const Error = error{
93 InvalidUrl,
94 UnsupportedScheme,
95 MissingHost,
96 ClientHostCapacityExceeded,
97 ClientTargetCapacityExceeded,
98 ClientPlainReadCapacityExceeded,
99 ClientPlainWriteCapacityExceeded,
100 };
101
102 pub const Prepared = struct {
103 host: []const u8,
104 target: []const u8,
105 port: u16,
106 is_tls: bool,
107 };
108
109 pub const ClientOperation = struct {
110 /// Parses `url` and writes its host into `scratch.host` and its target into
111 /// `scratch.target` without allocating, so the returned views are slices of
112 /// those two buffers. The port comes from the URL, or from the scheme when the
113 /// URL omits it: 443 for https and 80 for http. A scheme other than http or
114 /// https returns `error.UnsupportedScheme` , and a URL with no host returns
115 /// `error.MissingHost` . A host or target longer than its buffer returns a
116 /// capacity error and writes no prepared operation, so a caller can enlarge
117 /// that buffer and call again.
118 pub fn prepare(scratch: Scratch, url: []const u8) Error!Prepared {
119 const uri = std.Uri.parse(url) catch return error.InvalidUrl;
120 const is_http = std.ascii.eqlIgnoreCase(uri.scheme, "http");
121 const is_https = std.ascii.eqlIgnoreCase(uri.scheme, "https");
122 if (!is_http and !is_https) return error.UnsupportedScheme;
123 const host_component = uri.host orelse return error.MissingHost;
124 const host = try componentInto(
125 host_component,
126 scratch.host,
127 error.ClientHostCapacityExceeded,
128 );
129 const target = try targetInto(uri, scratch.target);
130 std.debug.assert(host.len <= scratch.host.len);
131 std.debug.assert(target.len <= scratch.target.len);
132 std.debug.assert(host.ptr == scratch.host.ptr);
133 std.debug.assert(target.ptr == scratch.target.ptr);
134 const default_port: u16 = if (is_https) 443 else 80;
135 return .{
136 .host = host,
137 .target = target,
138 .port = uri.port orelse default_port,
139 .is_tls = is_https,
140 };
141 }
142
143 pub fn validatePlain(scratch: Scratch) Error!void {
144 if (scratch.plain_read.len == 0) {
145 return error.ClientPlainReadCapacityExceeded;
146 }
147 if (scratch.plain_write.len == 0) {
148 return error.ClientPlainWriteCapacityExceeded;
149 }
150 std.debug.assert(scratch.plain_read.len > 0);
151 std.debug.assert(scratch.plain_write.len > 0);
152 }
153
154 pub fn writeRequest(
155 writer: *std.Io.Writer,
156 method: []const u8,
157 host: []const u8,
158 target: []const u8,
159 headers: []const response.Header,
160 body: []const u8,
161 ) std.Io.Writer.Error!void {
162 return writeRequestConnection(
163 writer,
164 method,
165 host,
166 target,
167 headers,
168 body,
169 "close",
170 );
171 }
172
173 pub fn writeReusableRequest(
174 writer: *std.Io.Writer,
175 method: []const u8,
176 host: []const u8,
177 target: []const u8,
178 headers: []const response.Header,
179 body: []const u8,
180 ) std.Io.Writer.Error!void {
181 return writeRequestConnection(
182 writer,
183 method,
184 host,
185 target,
186 headers,
187 body,
188 "keep-alive",
189 );
190 }
191
192 fn writeRequestConnection(
193 writer: *std.Io.Writer,
194 method: []const u8,
195 host: []const u8,
196 target: []const u8,
197 headers: []const response.Header,
198 body: []const u8,
199 connection: []const u8,
200 ) std.Io.Writer.Error!void {
201 std.debug.assert(method.len > 0);
202 std.debug.assert(host.len > 0);
203 std.debug.assert(target.len > 0);
204 std.debug.assert(connection.len > 0);
205 try writer.writeAll(method);
206 try writer.writeByte(' ');
207 try writer.writeAll(target);
208 try writer.writeAll(" HTTP/1.1\r\nHost: ");
209 try writer.writeAll(host);
210 try writer.writeAll("\r\n");
211 for (headers) |header| {
212 try writer.writeAll(header.name);
213 try writer.writeAll(": ");
214 try writer.writeAll(header.value);
215 try writer.writeAll("\r\n");
216 }
217 try writer.print("Content-Length: {d}\r\n", .{body.len});
218 try writer.writeAll("Connection: ");
219 try writer.writeAll(connection);
220 try writer.writeAll("\r\n\r\n");
221 try writer.writeAll(body);
222 }
223 };
224
225 fn componentInto(
226 component: std.Uri.Component,
227 destination: []u8,
228 capacity_error: Error,
229 ) Error![]const u8 {
230 const raw = component.toRaw(destination) catch return capacity_error;
231 if (raw.len > destination.len) return capacity_error;
232 if (raw.len != 0 and raw.ptr != destination.ptr) {
233 @memmove(destination[0..raw.len], raw);
234 }
235 std.debug.assert(raw.len <= destination.len);
236 return destination[0..raw.len];
237 }
238
239 fn targetInto(uri: std.Uri, destination: []u8) Error![]const u8 {
240 var length: usize = 0;
241 const path = try componentInto(
242 uri.path,
243 destination,
244 error.ClientTargetCapacityExceeded,
245 );
246 if (path.len == 0) {
247 if (destination.len == 0) return error.ClientTargetCapacityExceeded;
248 destination[0] = '/';
249 length = 1;
250 } else {
251 length = path.len;
252 }
253 if (uri.query) |query| {
254 if (length == destination.len) return error.ClientTargetCapacityExceeded;
255 destination[length] = '?';
256 length += 1;
257 const raw_query = try componentInto(
258 query,
259 destination[length..],
260 error.ClientTargetCapacityExceeded,
261 );
262 length += raw_query.len;
263 }
264 std.debug.assert(length <= destination.len);
265 return destination[0..length];
266 }
267
268 fn independentCapacity(limits: Limits) error{CapacityOverflow}!Capacity {
269 const host_bytes = @as(u128, limits.operation_count) * limits.host_bytes_per_operation;
270 const target_bytes = @as(u128, limits.operation_count) * limits.target_bytes_per_operation;
271 const plain_read_bytes = @as(u128, limits.operation_count) *
272 limits.plain_read_bytes_per_operation;
273 const plain_write_bytes = @as(u128, limits.operation_count) *
274 limits.plain_write_bytes_per_operation;
275 const storage_bytes = host_bytes + target_bytes + plain_read_bytes + plain_write_bytes;
276 if (host_bytes > std.math.maxInt(usize) or
277 target_bytes > std.math.maxInt(usize) or
278 plain_read_bytes > std.math.maxInt(usize) or
279 plain_write_bytes > std.math.maxInt(usize) or
280 storage_bytes > std.math.maxInt(usize))
281 {
282 return error.CapacityOverflow;
283 }
284 return .{
285 .operation_count = limits.operation_count,
286 .host_bytes_per_operation = limits.host_bytes_per_operation,
287 .target_bytes_per_operation = limits.target_bytes_per_operation,
288 .plain_read_bytes_per_operation = limits.plain_read_bytes_per_operation,
289 .plain_write_bytes_per_operation = limits.plain_write_bytes_per_operation,
290 .host_bytes = @intCast(host_bytes),
291 .target_bytes = @intCast(target_bytes),
292 .plain_read_bytes = @intCast(plain_read_bytes),
293 .plain_write_bytes = @intCast(plain_write_bytes),
294 .storage_bytes = @intCast(storage_bytes),
295 };
296 }
297
298 test "Client operation capacity matches independent arithmetic" {
299 comptime {
300 @stardustClaim(
301 @import("alloc_phase").capacity.witness(@import("./root.zig").ClientOperationStorage, "http_client_operation_capacity"),
302 null,
303 null,
304 null,
305 null,
306 null,
307 null,
308 );
309 }
310
311 for (0..4) |operation_count| {
312 for (0..4) |host_bytes_per_operation| {
313 for (0..4) |target_bytes_per_operation| {
314 for (0..4) |plain_read_bytes_per_operation| {
315 for (0..4) |plain_write_bytes_per_operation| {
316 const limits = Limits{
317 .operation_count = operation_count,
318 .host_bytes_per_operation = host_bytes_per_operation,
319 .target_bytes_per_operation = target_bytes_per_operation,
320 .plain_read_bytes_per_operation = plain_read_bytes_per_operation,
321 .plain_write_bytes_per_operation = plain_write_bytes_per_operation,
322 };
323 try std.testing.expectEqual(
324 try independentCapacity(limits),
325 try Capacity.derive(limits),
326 );
327 }
328 }
329 }
330 }
331 }
332 const maximum = std.math.maxInt(usize);
333 inline for (0..4) |field_index| {
334 var limits = Limits{
335 .operation_count = 2,
336 .host_bytes_per_operation = 0,
337 .target_bytes_per_operation = 0,
338 .plain_read_bytes_per_operation = 0,
339 .plain_write_bytes_per_operation = 0,
340 };
341 switch (field_index) {
342 0 => limits.host_bytes_per_operation = maximum,
343 1 => limits.target_bytes_per_operation = maximum,
344 2 => limits.plain_read_bytes_per_operation = maximum,
345 3 => limits.plain_write_bytes_per_operation = maximum,
346 else => unreachable,
347 }
348 try std.testing.expectError(error.CapacityOverflow, Capacity.derive(limits));
349 }
350 try std.testing.expectError(error.CapacityOverflow, Capacity.derive(.{
351 .operation_count = 1,
352 .host_bytes_per_operation = maximum,
353 .target_bytes_per_operation = 1,
354 .plain_read_bytes_per_operation = 0,
355 .plain_write_bytes_per_operation = 0,
356 }));
357 try std.testing.expectError(error.CapacityOverflow, Capacity.derive(.{
358 .operation_count = 1,
359 .host_bytes_per_operation = 0,
360 .target_bytes_per_operation = 0,
361 .plain_read_bytes_per_operation = maximum,
362 .plain_write_bytes_per_operation = 1,
363 }));
364 try std.testing.expectError(error.CapacityOverflow, Capacity.derive(.{
365 .operation_count = 1,
366 .host_bytes_per_operation = maximum,
367 .target_bytes_per_operation = 0,
368 .plain_read_bytes_per_operation = 1,
369 .plain_write_bytes_per_operation = 0,
370 }));
371 }
372
373 test "Client operation accepts exact host and target capacities" {
374 comptime {
375 @stardustClaim(
376 @import("alloc_phase").capacity.witness(@import("./root.zig").ClientOperationStorage, "http_client_operation_boundary"),
377 null,
378 null,
379 null,
380 null,
381 null,
382 null,
383 );
384 }
385
386 const host_text = "example.test";
387 const target_text = "/path?x=1&y=2";
388 var host: [host_text.len]u8 = undefined;
389 var target: [target_text.len]u8 = undefined;
390 var plain_read: [1]u8 = undefined;
391 var plain_write: [1]u8 = undefined;
392 const scratch = Scratch{
393 .host = &host,
394 .target = &target,
395 .plain_read = &plain_read,
396 .plain_write = &plain_write,
397 };
398 const prepared = try ClientOperation.prepare(
399 scratch,
400 "http://example.test/path?x=1&y=2",
401 );
402 try std.testing.expectEqualStrings(host_text, prepared.host);
403 try std.testing.expectEqualStrings(target_text, prepared.target);
404 try std.testing.expect(prepared.host.ptr == host[0..].ptr);
405 try std.testing.expect(prepared.target.ptr == target[0..].ptr);
406 try std.testing.expectEqual(@as(u16, 80), prepared.port);
407 try std.testing.expect(!prepared.is_tls);
408 try ClientOperation.validatePlain(scratch);
409 }
410
411 test "Client operation normalizes encoded URL components into caller storage" {
412 const expected_host = "example.test";
413 const expected_target = "/a b?q=x y";
414 var host: [expected_host.len]u8 = undefined;
415 var target: [expected_target.len]u8 = undefined;
416 const prepared = try ClientOperation.prepare(.{
417 .host = &host,
418 .target = &target,
419 .plain_read = &.{},
420 .plain_write = &.{},
421 }, "https://example%2Etest/a%20b?q=x%20y");
422 try std.testing.expectEqualStrings(expected_host, prepared.host);
423 try std.testing.expectEqualStrings(expected_target, prepared.target);
424 try std.testing.expect(prepared.host.ptr == host[0..].ptr);
425 try std.testing.expect(prepared.target.ptr == target[0..].ptr);
426 try std.testing.expectEqual(@as(u16, 443), prepared.port);
427 try std.testing.expect(prepared.is_tls);
428 }
429
430 test "Client operation supplies slash for empty request target" {
431 var host: [12]u8 = undefined;
432 var target: [1]u8 = undefined;
433 const prepared = try ClientOperation.prepare(.{
434 .host = &host,
435 .target = &target,
436 .plain_read = &.{},
437 .plain_write = &.{},
438 }, "https://example.test");
439 try std.testing.expectEqualStrings("/", prepared.target);
440 }
441
442 test "Client operation reports host target and plain window exhaustion" {
443 var host: [11]u8 = undefined;
444 var target: [13]u8 = undefined;
445 var exact_host: [12]u8 = undefined;
446 var short_target: [8]u8 = undefined;
447 var byte: [1]u8 = undefined;
448 try std.testing.expectError(
449 error.ClientHostCapacityExceeded,
450 ClientOperation.prepare(.{
451 .host = &host,
452 .target = &target,
453 .plain_read = &byte,
454 .plain_write = &byte,
455 }, "http://example.test/path?x=1"),
456 );
457 try std.testing.expectError(
458 error.ClientTargetCapacityExceeded,
459 ClientOperation.prepare(.{
460 .host = &exact_host,
461 .target = &short_target,
462 .plain_read = &byte,
463 .plain_write = &byte,
464 }, "http://example.test/path?x=1"),
465 );
466 try std.testing.expectError(
467 error.ClientPlainReadCapacityExceeded,
468 ClientOperation.validatePlain(.{
469 .host = &host,
470 .target = &target,
471 .plain_read = &.{},
472 .plain_write = &byte,
473 }),
474 );
475 try std.testing.expectError(
476 error.ClientPlainWriteCapacityExceeded,
477 ClientOperation.validatePlain(.{
478 .host = &host,
479 .target = &target,
480 .plain_read = &byte,
481 .plain_write = &.{},
482 }),
483 );
484 }
485
486 test "Client operation serializes request directly" {
487 var bytes: [512]u8 = undefined;
488 var writer = std.Io.Writer.fixed(&bytes);
489 try ClientOperation.writeRequest(
490 &writer,
491 "POST",
492 "api.example.com",
493 "/v1/complete",
494 &.{
495 .{ .name = "content-type", .value = "application/json" },
496 .{ .name = "authorization", .value = "Bearer sk-test" },
497 },
498 "{\"hello\":true}",
499 );
500 const request = writer.buffered();
501 try std.testing.expect(std.mem.startsWith(u8, request, "POST /v1/complete HTTP/1.1\r\n"));
502 try std.testing.expect(std.mem.indexOf(u8, request, "Host: api.example.com\r\n") != null);
503 try std.testing.expect(
504 std.mem.indexOf(u8, request, "content-type: application/json\r\n") != null,
505 );
506 try std.testing.expect(std.mem.indexOf(u8, request, "Content-Length: 14\r\n") != null);
507 try std.testing.expect(std.mem.indexOf(u8, request, "Connection: close\r\n") != null);
508 try std.testing.expect(std.mem.endsWith(u8, request, "\r\n\r\n{\"hello\":true}"));
509 }
510
511 test "Client reusable operation requests keep-alive" {
512 var bytes: [256]u8 = undefined;
513 var writer = std.Io.Writer.fixed(&bytes);
514 try ClientOperation.writeReusableRequest(
515 &writer,
516 "GET",
517 "relay.example",
518 "/service/v1/health",
519 &.{},
520 "",
521 );
522 const request = writer.buffered();
523 try std.testing.expect(
524 std.mem.indexOf(
525 u8,
526 request,
527 "Connection: keep-alive\r\n",
528 ) != null,
529 );
530 }