add Vary support

This commit is contained in:
Muki Kiboigo
2026-03-30 15:19:30 -07:00
parent 9ffc99d6a2
commit 7b5e4d6f52
5 changed files with 246 additions and 75 deletions

View File

@@ -95,23 +95,6 @@ pub const CacheControl = struct {
}
};
pub const Vary = union(enum) {
wildcard: void,
value: []const u8,
pub fn parse(value: []const u8) Vary {
if (std.mem.eql(u8, value, "*")) return .wildcard;
return .{ .value = value };
}
pub fn toString(self: Vary) []const u8 {
return switch (self) {
.wildcard => "*",
.value => |v| v,
};
}
};
pub const CachedMetadata = struct {
url: [:0]const u8,
content_type: []const u8,
@@ -126,13 +109,17 @@ pub const CachedMetadata = struct {
last_modified: ?[]const u8,
cache_control: CacheControl,
vary: ?Vary,
/// Response Headers
headers: []const Http.Header,
/// These are Request Headers used by Vary.
vary_headers: []const Http.Header,
};
pub const CacheRequest = struct {
url: []const u8,
timestamp: i64,
request_headers: []const Http.Header,
};
pub const CachedData = union(enum) {
@@ -166,6 +153,7 @@ pub fn tryCache(
if (status != 200) return null;
if (has_set_cookie) return null;
if (has_authorization) return null;
if (vary) |v| if (std.mem.eql(u8, v, "*")) return null;
const cc = CacheControl.parse(cache_control orelse return null) orelse return null;
return .{
@@ -175,9 +163,9 @@ pub fn tryCache(
.stored_at = timestamp,
.age_at_store = if (age) |a| std.fmt.parseInt(u64, a, 10) catch 0 else 0,
.cache_control = cc,
.vary = if (vary) |v| Vary.parse(v) else null,
.etag = if (etag) |e| try arena.dupe(u8, e) else null,
.last_modified = if (last_modified) |lm| try arena.dupe(u8, lm) else null,
.headers = &.{},
.vary_headers = &.{},
};
}

View File

@@ -154,6 +154,7 @@ pub fn get(self: *FsCache, arena: std.mem.Allocator, req: CacheRequest) ?Cache.C
const metadata = cache_file.metadata;
// Check entry expiration.
const now = req.timestamp;
const age = (now - metadata.stored_at) + @as(i64, @intCast(metadata.age_at_store));
if (age < 0 or @as(u64, @intCast(age)) >= metadata.cache_control.max_age) {
@@ -162,6 +163,28 @@ pub fn get(self: *FsCache, arena: std.mem.Allocator, req: CacheRequest) ?Cache.C
return null;
}
// If we have Vary headers, ensure they are present & matching.
for (metadata.vary_headers) |vary_hdr| {
const name = vary_hdr.name;
const value = vary_hdr.value;
const incoming = for (req.request_headers) |h| {
if (std.ascii.eqlIgnoreCase(h.name, name)) break h.value;
} else "";
if (!std.ascii.eqlIgnoreCase(value, incoming)) {
log.debug(.cache, "vary mismatch", .{ .url = req.url, .header = name });
return null;
}
}
// On the case of a hash collision.
if (!std.ascii.eqlIgnoreCase(metadata.url, req.url)) {
log.warn(.cache, "collision", .{ .url = req.url, .expected = metadata.url, .got = req.url });
cleanup = true;
return null;
}
return .{
.metadata = metadata,
.data = .{
@@ -243,8 +266,8 @@ test "FsCache: basic put and get" {
.etag = null,
.last_modified = null,
.cache_control = .{ .max_age = 600 },
.vary = null,
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
@@ -252,7 +275,11 @@ test "FsCache: basic put and get" {
const result = cache.get(
arena.allocator(),
.{ .url = "https://example.com", .timestamp = now },
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
@@ -291,8 +318,8 @@ test "FsCache: get expiration" {
.etag = null,
.last_modified = null,
.cache_control = .{ .max_age = max_age },
.vary = null,
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
@@ -300,18 +327,30 @@ test "FsCache: get expiration" {
const result = cache.get(
arena.allocator(),
.{ .url = "https://example.com", .timestamp = now + 50 },
.{
.url = "https://example.com",
.timestamp = now + 50,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
result.data.file.file.close();
try testing.expectEqual(null, cache.get(
arena.allocator(),
.{ .url = "https://example.com", .timestamp = now + 200 },
.{
.url = "https://example.com",
.timestamp = now + 200,
.request_headers = &.{},
},
));
try testing.expectEqual(null, cache.get(
arena.allocator(),
.{ .url = "https://example.com", .timestamp = now },
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
));
}
@@ -340,8 +379,8 @@ test "FsCache: put override" {
.etag = null,
.last_modified = null,
.cache_control = .{ .max_age = max_age },
.vary = null,
.headers = &.{},
.vary_headers = &.{},
};
const body = "hello world";
@@ -349,7 +388,11 @@ test "FsCache: put override" {
const result = cache.get(
arena.allocator(),
.{ .url = "https://example.com", .timestamp = now },
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
@@ -378,8 +421,8 @@ test "FsCache: put override" {
.etag = null,
.last_modified = null,
.cache_control = .{ .max_age = max_age },
.vary = null,
.headers = &.{},
.vary_headers = &.{},
};
const body = "goodbye world";
@@ -387,7 +430,11 @@ test "FsCache: put override" {
const result = cache.get(
arena.allocator(),
.{ .url = "https://example.com", .timestamp = now },
.{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
},
) orelse return error.CacheMiss;
const f = result.data.file;
const file = f.file;
@@ -422,6 +469,124 @@ test "FsCache: garbage file" {
try testing.expectEqual(
null,
setup.cache.get(arena.allocator(), .{ .url = "https://example.com", .timestamp = 5000 }),
setup.cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = 5000,
.request_headers = &.{},
}),
);
}
test "FsCache: vary hit and miss" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = std.time.timestamp();
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.etag = null,
.last_modified = null,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
};
try cache.put(meta, "hello world");
const result = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
}) orelse return error.CacheMiss;
result.data.file.file.close();
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "br" },
},
}));
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{},
}));
const result2 = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
},
}) orelse return error.CacheMiss;
result2.data.file.file.close();
}
test "FsCache: vary multiple headers" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = std.time.timestamp();
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.etag = null,
.last_modified = null,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "en" },
},
};
try cache.put(meta, "hello world");
const result = cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "en" },
},
}) orelse return error.CacheMiss;
result.data.file.file.close();
try testing.expectEqual(null, cache.get(arena.allocator(), .{
.url = "https://example.com",
.timestamp = now,
.request_headers = &.{
.{ .name = "Accept-Encoding", .value = "gzip" },
.{ .name = "Accept-Language", .value = "fr" },
},
}));
}