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 }