Merge pull request #90 from jetzig-framework/file-uploads

Implement file upload support
This commit is contained in:
bobf 2024-06-19 21:00:09 +01:00 committed by GitHub
commit bed5241316
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 252 additions and 14 deletions

View File

@ -7,24 +7,24 @@
.hash = "12203b56c2e17a2fd62ea3d3d9be466f43921a3aef88b381cf58f41251815205fdb5", .hash = "12203b56c2e17a2fd62ea3d3d9be466f43921a3aef88b381cf58f41251815205fdb5",
}, },
.zmpl = .{ .zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/676969d44fe1c3adabd73af983f33aefd8aed1b9.tar.gz", .url = "https://github.com/jetzig-framework/zmpl/archive/7c2e599807fe8d28ce45b8b3be1829e3d704422e.tar.gz",
.hash = "1220a6cce7678578e0176796c19d3810cdd3a9c1b0792a3731a8cd25d2aee96bd78a", .hash = "12207c30c6fbcb8c7519719fc47ff9d0acca72a3557ec671984d16260bdf1c832740",
}, },
.jetkv = .{ .jetkv = .{
.url = "https://github.com/jetzig-framework/jetkv/archive/78bcdcc6b0cbd3ca808685c64554a15701f13250.tar.gz", .url = "https://github.com/jetzig-framework/jetkv/archive/78bcdcc6b0cbd3ca808685c64554a15701f13250.tar.gz",
.hash = "12201944769794b2f18e3932ec3d5031066d0ccb3293cf7c3fb1f5b269e56b76c57e", .hash = "12201944769794b2f18e3932ec3d5031066d0ccb3293cf7c3fb1f5b269e56b76c57e",
}, },
.args = .{ .args = .{
.url = "https://github.com/ikskuh/zig-args/archive/872272205d95bdba33798c94e72c5387a31bc806.tar.gz", .url = "https://github.com/ikskuh/zig-args/archive/03af1b6c5bfda9646a562c861055024daed5b238.tar.gz",
.hash = "1220fe6ae56b668cc4a033282b5f227bfbb46a67ede6d84e9f9493fea9de339b5f37", .hash = "1220904d2fdcd970dd0d216211d092eb3ef6da01117163cc9393ab845a1d66c029d9",
}, },
.smtp_client = .{ .smtp_client = .{
.url = "https://github.com/karlseguin/smtp_client.zig/archive/964152ad4e19dc1d22f6def6f659c86df60e7832.tar.gz", .url = "https://github.com/karlseguin/smtp_client.zig/archive/964152ad4e19dc1d22f6def6f659c86df60e7832.tar.gz",
.hash = "1220d4f1c2472769b0d689ea878f41f0a66cb07f28569a138aea2c0a648a5c90dd4e", .hash = "1220d4f1c2472769b0d689ea878f41f0a66cb07f28569a138aea2c0a648a5c90dd4e",
}, },
.httpz = .{ .httpz = .{
.url = "https://github.com/karlseguin/http.zig/archive/12764925eb6a7929004c1be9032b04f97f4e43e2.tar.gz", .url = "https://github.com/karlseguin/http.zig/archive/fbca868592dc83ee3ee3cad414c62afa266f4866.tar.gz",
.hash = "1220ffb589c6cd1a040bfd4446c74c38b5e873ba82e737cb33c98711c95787b92c81", .hash = "122089946af5ba1cdfae3f515f0fa1c96327f42a462515fbcecc719fe94fab38d9b8",
}, },
}, },

View File

@ -0,0 +1,47 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
var root = try data.root(.object);
const params = try request.params();
if (try request.file("upload")) |file| {
try root.put("description", params.getT(.string, "description"));
try root.put("filename", file.filename);
try root.put("content", file.content);
try root.put("uploaded", true);
}
return request.render(.created);
}
test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/file_upload", .{});
try response.expectStatus(.ok);
}
test "post" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.POST, "/file_upload", .{
.body = app.multipart(.{
.description = "example description",
.upload = jetzig.testing.file("example.txt", "example file content"),
}),
});
try response.expectStatus(.created);
try response.expectBodyContains("example description");
try response.expectBodyContains("example.txt");
try response.expectBodyContains("example file content");
}

View File

@ -0,0 +1,7 @@
<form action="/file_upload" enctype="multipart/form-data" method="POST">
<label>Filename</label>
<input type="text" name="description" />
<label>File</label>
<input type="file" name="upload" />
<input type="submit" value="Submit" />
</form>

View File

@ -0,0 +1,10 @@
<h1>File Uploaded Successfully</h1>
<h2>Description</h2>
<p>{{.description}}</p>
<h2>Filename</h2>
<p>{{.filename}}</p>
<h2>Content</h2>
<p>{{.content}}</p>

View File

@ -32,6 +32,9 @@ pub const jetzig_options = struct {
// This can be increased if needed. // This can be increased if needed.
pub const max_bytes_header_name: u16 = 40; pub const max_bytes_header_name: u16 = 40;
/// Maximum number of `multipart/form-data`-encoded fields to accept per request.
pub const max_multipart_form_fields: usize = 20;
// Log message buffer size. Log messages exceeding this size spill to heap with degraded // Log message buffer size. Log messages exceeding this size spill to heap with degraded
// performance. Log messages should aim to fit in the message buffer. // performance. Log messages should aim to fit in the message buffer.
pub const log_message_buffer_len: usize = 4096; pub const log_message_buffer_len: usize = 4096;

View File

@ -89,6 +89,9 @@ pub const config = struct {
/// This can be increased if needed. /// This can be increased if needed.
pub const max_bytes_header_name: u16 = 40; pub const max_bytes_header_name: u16 = 40;
/// Maximum number of `multipart/form-data`-encoded fields to accept per request.
pub const max_multipart_form_fields: usize = 20;
/// Log message buffer size. Log messages exceeding this size spill to heap with degraded /// Log message buffer size. Log messages exceeding this size spill to heap with degraded
/// performance. Log messages should aim to fit in the message buffer. /// performance. Log messages should aim to fit in the message buffer.
pub const log_message_buffer_len: usize = 4096; pub const log_message_buffer_len: usize = 4096;

View File

@ -9,6 +9,8 @@ pub const Session = @import("http/Session.zig");
pub const Cookies = @import("http/Cookies.zig"); pub const Cookies = @import("http/Cookies.zig");
pub const Headers = @import("http/Headers.zig"); pub const Headers = @import("http/Headers.zig");
pub const Query = @import("http/Query.zig"); pub const Query = @import("http/Query.zig");
pub const MultipartQuery = @import("http/MultipartQuery.zig");
pub const File = @import("http/File.zig");
pub const Path = @import("http/Path.zig"); pub const Path = @import("http/Path.zig");
pub const status_codes = @import("http/status_codes.zig"); pub const status_codes = @import("http/status_codes.zig");
pub const StatusCode = status_codes.StatusCode; pub const StatusCode = status_codes.StatusCode;

4
src/jetzig/http/File.zig Normal file
View File

@ -0,0 +1,4 @@
const std = @import("std");
filename: []const u8,
content: []const u8,

View File

@ -0,0 +1,46 @@
const std = @import("std");
const httpz = @import("httpz");
const jetzig = @import("../../jetzig.zig");
allocator: std.mem.Allocator,
key_value: httpz.key_value.MultiFormKeyValue,
const MultipartQuery = @This();
/// Fetch a file from multipart form data, if present.
pub fn getFile(self: MultipartQuery, key: []const u8) ?jetzig.http.File {
const keys = self.key_value.keys;
const values = self.key_value.values;
for (keys[0..self.key_value.len], values[0..self.key_value.len]) |name, field| {
const filename = field.filename orelse continue;
if (std.mem.eql(u8, name, key)) return jetzig.http.File{
.filename = filename,
.content = field.value,
};
}
return null;
}
/// Return all params in a multipart form submission **excluding** files. Use
/// `jetzig.http.Request.getFile` to read a file object (includes filename and data).
pub fn params(self: MultipartQuery) !*jetzig.data.Data {
const data = try self.allocator.create(jetzig.data.Data);
data.* = jetzig.data.Data.init(self.allocator);
var root = try data.root(.object);
const keys = self.key_value.keys;
const values = self.key_value.values;
for (keys[0..self.key_value.len], values[0..self.key_value.len]) |name, field| {
if (field.filename != null) continue;
try root.put(name, field.value);
}
return data;
}

View File

@ -23,6 +23,8 @@ status_code: jetzig.http.status_codes.StatusCode = .not_found,
response_data: *jetzig.data.Data, response_data: *jetzig.data.Data,
query_params: ?*jetzig.http.Query = null, query_params: ?*jetzig.http.Query = null,
query_body: ?*jetzig.http.Query = null, query_body: ?*jetzig.http.Query = null,
multipart: ?jetzig.http.MultipartQuery = null,
parsed_multipart: ?*jetzig.data.Data = null,
_cookies: ?*jetzig.http.Cookies = null, _cookies: ?*jetzig.http.Cookies = null,
_session: ?*jetzig.http.Session = null, _session: ?*jetzig.http.Session = null,
body: []const u8 = undefined, body: []const u8 = undefined,
@ -307,6 +309,16 @@ pub fn params(self: *Request) !*jetzig.data.Value {
} }
} }
/// Retrieve a file from a `multipart/form-data`-encoded request body, if present.
pub fn file(self: *Request, name: []const u8) !?jetzig.http.File {
_ = try self.parseQuery();
if (self.multipart) |multipart| {
return multipart.getFile(name);
} else {
return null;
}
}
/// Return a `*Value` representing request parameters. This function **always** returns the /// Return a `*Value` representing request parameters. This function **always** returns the
/// parsed query string and never the request body. /// parsed query string and never the request body.
pub fn queryParams(self: *Request) !*jetzig.data.Value { pub fn queryParams(self: *Request) !*jetzig.data.Value {
@ -328,6 +340,20 @@ pub fn queryParams(self: *Request) !*jetzig.data.Value {
fn parseQuery(self: *Request) !*jetzig.data.Value { fn parseQuery(self: *Request) !*jetzig.data.Value {
if (self.body.len == 0) return try self.queryParams(); if (self.body.len == 0) return try self.queryParams();
if (self.query_body) |parsed| return parsed.data.value.?; if (self.query_body) |parsed| return parsed.data.value.?;
if (self.parsed_multipart) |parsed| return parsed.value.?;
const maybe_multipart = self.httpz_request.multiFormData() catch |err| blk: {
switch (err) {
error.NotMultipartForm => break :blk null,
else => return err,
}
};
if (maybe_multipart) |multipart| {
self.multipart = jetzig.http.MultipartQuery{ .allocator = self.allocator, .key_value = multipart };
self.parsed_multipart = try self.multipart.?.params();
return self.parsed_multipart.?.value.?;
}
const data = try self.allocator.create(jetzig.data.Data); const data = try self.allocator.create(jetzig.data.Data);
data.* = jetzig.data.Data.init(self.allocator); data.* = jetzig.data.Data.init(self.allocator);
@ -337,7 +363,9 @@ fn parseQuery(self: *Request) !*jetzig.data.Value {
self.body, self.body,
data, data,
); );
try self.query_body.?.parse(); try self.query_body.?.parse();
return self.query_body.?.data.value.?; return self.query_body.?.data.value.?;
} }

View File

@ -93,6 +93,9 @@ pub fn listen(self: *Server) !void {
.max_conn = jetzig.config.get(u16, "max_connections"), .max_conn = jetzig.config.get(u16, "max_connections"),
.retain_allocated_bytes = jetzig.config.get(usize, "arena_size"), .retain_allocated_bytes = jetzig.config.get(usize, "arena_size"),
}, },
.request = .{
.max_multiform_count = jetzig.config.get(usize, "max_multipart_form_fields"),
},
}, },
Dispatcher{ .server = self }, Dispatcher{ .server = self },
); );
@ -626,7 +629,6 @@ fn matchPublicContent(self: *Server, request: *jetzig.http.Request) !?StaticReso
fn matchStaticContent(self: *Server, request: *jetzig.http.Request) !?[]const u8 { fn matchStaticContent(self: *Server, request: *jetzig.http.Request) !?[]const u8 {
const request_format = request.requestFormat(); const request_format = request.requestFormat();
const matched_route = try self.matchRoute(request, true); const matched_route = try self.matchRoute(request, true);
const params = try request.params();
if (matched_route) |route| { if (matched_route) |route| {
if (@hasDecl(jetzig.root, "static")) { if (@hasDecl(jetzig.root, "static")) {
@ -634,6 +636,8 @@ fn matchStaticContent(self: *Server, request: *jetzig.http.Request) !?[]const u8
if (!@hasField(@TypeOf(static_output), "route_id")) continue; if (!@hasField(@TypeOf(static_output), "route_id")) continue;
if (std.mem.eql(u8, static_output.route_id, route.id)) { if (std.mem.eql(u8, static_output.route_id, route.id)) {
const params = try request.params();
if (index < self.decoded_static_route_params.len) { if (index < self.decoded_static_route_params.len) {
if (matchStaticOutput( if (matchStaticOutput(
self.decoded_static_route_params[index].getT(.string, "id"), self.decoded_static_route_params[index].getT(.string, "id"),

View File

@ -293,12 +293,13 @@ pub fn expectJob(job_name: []const u8, job_params: anytype, response: TestRespon
return error.JetzigExpectJobError; return error.JetzigExpectJobError;
} }
// fn log(comptime message: []const u8, args: anytype) void { pub const File = struct { filename: []const u8, content: []const u8 };
// std.log.info("[jetzig.testing] " ++ message ++ "\n", args); pub fn file(comptime filename: []const u8, comptime content: []const u8) File {
// } return .{ .filename = filename, .content = content };
}
fn logFailure(comptime message: []const u8, args: anytype) void { fn logFailure(comptime message: []const u8, args: anytype) void {
std.log.err(message, args); std.log.err("[jetzig.testing] " ++ message, args);
} }
fn jsonPretty(response: TestResponse) ![]const u8 { fn jsonPretty(response: TestResponse) ![]const u8 {

View File

@ -11,9 +11,11 @@ arena: *std.heap.ArenaAllocator,
store: *jetzig.kv.Store, store: *jetzig.kv.Store,
cache: *jetzig.kv.Store, cache: *jetzig.kv.Store,
job_queue: *jetzig.kv.Store, job_queue: *jetzig.kv.Store,
multipart_boundary: ?[]const u8 = null,
const initHook = jetzig.root.initHook; const initHook = jetzig.root.initHook;
/// Initialize a new test app.
pub fn init(allocator: std.mem.Allocator, routes_module: type) !App { pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
switch (jetzig.testing.state) { switch (jetzig.testing.state) {
.ready => {}, .ready => {},
@ -39,6 +41,7 @@ pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
}; };
} }
/// Free allocated resources for test app.
pub fn deinit(self: *App) void { pub fn deinit(self: *App) void {
self.arena.deinit(); self.arena.deinit();
self.allocator.destroy(self.arena); self.allocator.destroy(self.arena);
@ -48,6 +51,21 @@ const RequestOptions = struct {
headers: []const jetzig.testing.TestResponse.Header = &.{}, headers: []const jetzig.testing.TestResponse.Header = &.{},
json: ?[]const u8 = null, json: ?[]const u8 = null,
params: ?[]Param = null, params: ?[]Param = null,
body: ?[]const u8 = null,
pub fn getBody(self: RequestOptions) ?[]const u8 {
if (self.json) |capture| return capture;
if (self.body) |capture| return capture;
return null;
}
pub fn bodyLen(self: RequestOptions) usize {
if (self.json) |capture| return capture.len;
if (self.body) |capture| return capture.len;
return 0;
}
}; };
const Param = struct { const Param = struct {
@ -55,6 +73,7 @@ const Param = struct {
value: ?[]const u8, value: ?[]const u8,
}; };
/// Issue a request to the test server.
pub fn request( pub fn request(
self: *App, self: *App,
comptime method: jetzig.http.Request.Method, comptime method: jetzig.http.Request.Method,
@ -93,7 +112,7 @@ pub fn request(
try server.decodeStaticParams(); try server.decodeStaticParams();
var buf: [1024]u8 = undefined; var buf: [1024]u8 = undefined;
var httpz_request = try stubbedRequest(allocator, &buf, method, path, options); var httpz_request = try stubbedRequest(allocator, &buf, method, path, self.multipart_boundary, options);
var httpz_response = try stubbedResponse(allocator); var httpz_response = try stubbedResponse(allocator);
try server.processNextRequest(&httpz_request, &httpz_response); try server.processNextRequest(&httpz_request, &httpz_response);
var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(self.arena.allocator()); var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(self.arena.allocator());
@ -122,6 +141,7 @@ pub fn request(
}; };
} }
/// Generate query params to use with a request.
pub fn params(self: App, args: anytype) []Param { pub fn params(self: App, args: anytype) []Param {
const allocator = self.arena.allocator(); const allocator = self.arena.allocator();
var array = std.ArrayList(Param).init(allocator); var array = std.ArrayList(Param).init(allocator);
@ -131,16 +151,61 @@ pub fn params(self: App, args: anytype) []Param {
return array.toOwnedSlice() catch @panic("OOM"); return array.toOwnedSlice() catch @panic("OOM");
} }
/// Encode an arbitrary struct to a JSON string for use as a request body.
pub fn json(self: App, args: anytype) []const u8 { pub fn json(self: App, args: anytype) []const u8 {
const allocator = self.arena.allocator(); const allocator = self.arena.allocator();
return std.json.stringifyAlloc(allocator, args, .{}) catch @panic("OOM"); return std.json.stringifyAlloc(allocator, args, .{}) catch @panic("OOM");
} }
/// Generate a `multipart/form-data`-encoded request body.
pub fn multipart(self: *App, comptime args: anytype) []const u8 {
var buf = std.ArrayList(u8).init(self.arena.allocator());
const writer = buf.writer();
var boundary_buf: [16]u8 = undefined;
const boundary = jetzig.util.generateRandomString(&boundary_buf);
self.multipart_boundary = boundary;
inline for (@typeInfo(@TypeOf(args)).Struct.fields, 0..) |field, index| {
if (index > 0) tryWrite(writer, "\r\n");
tryWrite(writer, "--");
tryWrite(writer, boundary);
tryWrite(writer, "\r\n");
switch (@TypeOf(@field(args, field.name))) {
jetzig.testing.File => {
const header = std.fmt.comptimePrint(
\\Content-Disposition: form-data; name="{s}"; filename="{s}"
, .{ field.name, @field(args, field.name).filename });
tryWrite(writer, header ++ "\r\n\r\n");
tryWrite(writer, @field(args, field.name).content);
},
// Assume a string, let Zig fail for us if not.
else => {
tryWrite(
writer,
"Content-Disposition: form-data; name=\"" ++ field.name ++ "\"\r\n\r\n",
);
tryWrite(writer, @field(args, field.name));
},
}
}
tryWrite(writer, "\r\n--");
tryWrite(writer, boundary);
tryWrite(writer, "--\r\n");
return buf.toOwnedSlice() catch @panic("OOM");
}
fn tryWrite(writer: anytype, data: []const u8) void {
writer.writeAll(data) catch @panic("OOM");
}
fn stubbedRequest( fn stubbedRequest(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
buf: []u8, buf: []u8,
comptime method: jetzig.http.Request.Method, comptime method: jetzig.http.Request.Method,
comptime path: []const u8, comptime path: []const u8,
multipart_boundary: ?[]const u8,
options: RequestOptions, options: RequestOptions,
) !httpz.Request { ) !httpz.Request {
var request_headers = try keyValue(allocator, 32); var request_headers = try keyValue(allocator, 32);
@ -148,6 +213,9 @@ fn stubbedRequest(
if (options.json != null) { if (options.json != null) {
request_headers.add("accept", "application/json"); request_headers.add("accept", "application/json");
request_headers.add("content-type", "application/json"); request_headers.add("content-type", "application/json");
} else if (multipart_boundary) |boundary| {
const header = try std.mem.concat(allocator, u8, &.{ "multipart/form-data; boundary=", boundary });
request_headers.add("content-type", header);
} }
var params_buf = std.ArrayList([]const u8).init(allocator); var params_buf = std.ArrayList([]const u8).init(allocator);
@ -174,8 +242,11 @@ fn stubbedRequest(
.protocol = .HTTP11, .protocol = .HTTP11,
.params = undefined, .params = undefined,
.headers = request_headers, .headers = request_headers,
.body_buffer = if (options.json) |capture| .{ .data = @constCast(capture), .type = .static } else null, .body_buffer = if (options.getBody()) |capture|
.body_len = if (options.json) |capture| capture.len else 0, .{ .data = @constCast(capture), .type = .static }
else
null,
.body_len = options.bodyLen(),
.qs = try keyValue(allocator, 32), .qs = try keyValue(allocator, 32),
.fd = try keyValue(allocator, 32), .fd = try keyValue(allocator, 32),
.mfd = try multiFormKeyValue(allocator, 32), .mfd = try multiFormKeyValue(allocator, 32),
@ -225,6 +296,7 @@ fn buildOptions(app: *const App, args: anytype) RequestOptions {
if (std.mem.eql(u8, field.name, "headers")) continue; if (std.mem.eql(u8, field.name, "headers")) continue;
if (std.mem.eql(u8, field.name, "json")) continue; if (std.mem.eql(u8, field.name, "json")) continue;
if (std.mem.eql(u8, field.name, "params")) continue; if (std.mem.eql(u8, field.name, "params")) continue;
if (std.mem.eql(u8, field.name, "body")) continue;
} }
@compileError("Unrecognized request option: " ++ field.name); @compileError("Unrecognized request option: " ++ field.name);
@ -234,5 +306,6 @@ fn buildOptions(app: *const App, args: anytype) RequestOptions {
.headers = if (@hasField(@TypeOf(args), "headers")) args.headers else &.{}, .headers = if (@hasField(@TypeOf(args), "headers")) args.headers else &.{},
.json = if (@hasField(@TypeOf(args), "json")) app.json(args.json) else null, .json = if (@hasField(@TypeOf(args), "json")) app.json(args.json) else null,
.params = if (@hasField(@TypeOf(args), "params")) app.params(args.params) else null, .params = if (@hasField(@TypeOf(args), "params")) app.params(args.params) else null,
.body = if (@hasField(@TypeOf(args), "body")) args.body else null,
}; };
} }

View File

@ -69,6 +69,16 @@ pub fn generateSecret(allocator: std.mem.Allocator, comptime len: u10) ![]const
return try allocator.dupe(u8, &secret); return try allocator.dupe(u8, &secret);
} }
pub fn generateRandomString(buf: []u8) []const u8 {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
for (0..buf.len) |index| {
buf[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)];
}
return buf;
}
/// Calculate a duration from a given start time (in nanoseconds) to the current time. /// Calculate a duration from a given start time (in nanoseconds) to the current time.
pub fn duration(start_time: i128) i64 { pub fn duration(start_time: i128) i64 {
return @intCast(std.time.nanoTimestamp() - start_time); return @intCast(std.time.nanoTimestamp() - start_time);