mirror of
https://github.com/jetzig-framework/jetzig.git
synced 2025-05-14 14:06:08 +00:00
Close #89: Implement file upload support
Use `request.file("form-field-name")` to try to find a multipart-encoded form value for the given name. Returns `jetzig.http.File` if a match is found which provides `content` (uploaded file content) and `filename` (filename as passed by browser).
This commit is contained in:
parent
7805dd3cfa
commit
9971cde875
@ -7,24 +7,24 @@
|
||||
.hash = "12203b56c2e17a2fd62ea3d3d9be466f43921a3aef88b381cf58f41251815205fdb5",
|
||||
},
|
||||
.zmpl = .{
|
||||
.url = "https://github.com/jetzig-framework/zmpl/archive/676969d44fe1c3adabd73af983f33aefd8aed1b9.tar.gz",
|
||||
.hash = "1220a6cce7678578e0176796c19d3810cdd3a9c1b0792a3731a8cd25d2aee96bd78a",
|
||||
.url = "https://github.com/jetzig-framework/zmpl/archive/7c2e599807fe8d28ce45b8b3be1829e3d704422e.tar.gz",
|
||||
.hash = "12207c30c6fbcb8c7519719fc47ff9d0acca72a3557ec671984d16260bdf1c832740",
|
||||
},
|
||||
.jetkv = .{
|
||||
.url = "https://github.com/jetzig-framework/jetkv/archive/78bcdcc6b0cbd3ca808685c64554a15701f13250.tar.gz",
|
||||
.hash = "12201944769794b2f18e3932ec3d5031066d0ccb3293cf7c3fb1f5b269e56b76c57e",
|
||||
},
|
||||
.args = .{
|
||||
.url = "https://github.com/ikskuh/zig-args/archive/872272205d95bdba33798c94e72c5387a31bc806.tar.gz",
|
||||
.hash = "1220fe6ae56b668cc4a033282b5f227bfbb46a67ede6d84e9f9493fea9de339b5f37",
|
||||
.url = "https://github.com/ikskuh/zig-args/archive/03af1b6c5bfda9646a562c861055024daed5b238.tar.gz",
|
||||
.hash = "1220904d2fdcd970dd0d216211d092eb3ef6da01117163cc9393ab845a1d66c029d9",
|
||||
},
|
||||
.smtp_client = .{
|
||||
.url = "https://github.com/karlseguin/smtp_client.zig/archive/964152ad4e19dc1d22f6def6f659c86df60e7832.tar.gz",
|
||||
.hash = "1220d4f1c2472769b0d689ea878f41f0a66cb07f28569a138aea2c0a648a5c90dd4e",
|
||||
},
|
||||
.httpz = .{
|
||||
.url = "https://github.com/karlseguin/http.zig/archive/12764925eb6a7929004c1be9032b04f97f4e43e2.tar.gz",
|
||||
.hash = "1220ffb589c6cd1a040bfd4446c74c38b5e873ba82e737cb33c98711c95787b92c81",
|
||||
.url = "https://github.com/karlseguin/http.zig/archive/fbca868592dc83ee3ee3cad414c62afa266f4866.tar.gz",
|
||||
.hash = "122089946af5ba1cdfae3f515f0fa1c96327f42a462515fbcecc719fe94fab38d9b8",
|
||||
},
|
||||
},
|
||||
|
||||
|
47
demo/src/app/views/file_upload.zig
Normal file
47
demo/src/app/views/file_upload.zig
Normal 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");
|
||||
}
|
7
demo/src/app/views/file_upload/index.zmpl
Normal file
7
demo/src/app/views/file_upload/index.zmpl
Normal 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>
|
10
demo/src/app/views/file_upload/post.zmpl
Normal file
10
demo/src/app/views/file_upload/post.zmpl
Normal 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>
|
@ -32,6 +32,9 @@ pub const jetzig_options = struct {
|
||||
// This can be increased if needed.
|
||||
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
|
||||
// performance. Log messages should aim to fit in the message buffer.
|
||||
pub const log_message_buffer_len: usize = 4096;
|
||||
|
@ -89,6 +89,9 @@ pub const config = struct {
|
||||
/// This can be increased if needed.
|
||||
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
|
||||
/// performance. Log messages should aim to fit in the message buffer.
|
||||
pub const log_message_buffer_len: usize = 4096;
|
||||
|
@ -9,6 +9,8 @@ pub const Session = @import("http/Session.zig");
|
||||
pub const Cookies = @import("http/Cookies.zig");
|
||||
pub const Headers = @import("http/Headers.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 status_codes = @import("http/status_codes.zig");
|
||||
pub const StatusCode = status_codes.StatusCode;
|
||||
|
4
src/jetzig/http/File.zig
Normal file
4
src/jetzig/http/File.zig
Normal file
@ -0,0 +1,4 @@
|
||||
const std = @import("std");
|
||||
|
||||
filename: []const u8,
|
||||
content: []const u8,
|
46
src/jetzig/http/MultipartQuery.zig
Normal file
46
src/jetzig/http/MultipartQuery.zig
Normal 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;
|
||||
}
|
@ -23,6 +23,8 @@ status_code: jetzig.http.status_codes.StatusCode = .not_found,
|
||||
response_data: *jetzig.data.Data,
|
||||
query_params: ?*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,
|
||||
_session: ?*jetzig.http.Session = null,
|
||||
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
|
||||
/// parsed query string and never the request body.
|
||||
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 {
|
||||
if (self.body.len == 0) return try self.queryParams();
|
||||
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);
|
||||
data.* = jetzig.data.Data.init(self.allocator);
|
||||
@ -337,7 +363,9 @@ fn parseQuery(self: *Request) !*jetzig.data.Value {
|
||||
self.body,
|
||||
data,
|
||||
);
|
||||
|
||||
try self.query_body.?.parse();
|
||||
|
||||
return self.query_body.?.data.value.?;
|
||||
}
|
||||
|
||||
|
@ -93,6 +93,9 @@ pub fn listen(self: *Server) !void {
|
||||
.max_conn = jetzig.config.get(u16, "max_connections"),
|
||||
.retain_allocated_bytes = jetzig.config.get(usize, "arena_size"),
|
||||
},
|
||||
.request = .{
|
||||
.max_multiform_count = jetzig.config.get(usize, "max_multipart_form_fields"),
|
||||
},
|
||||
},
|
||||
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 {
|
||||
const request_format = request.requestFormat();
|
||||
const matched_route = try self.matchRoute(request, true);
|
||||
const params = try request.params();
|
||||
|
||||
if (matched_route) |route| {
|
||||
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 (std.mem.eql(u8, static_output.route_id, route.id)) {
|
||||
const params = try request.params();
|
||||
|
||||
if (index < self.decoded_static_route_params.len) {
|
||||
if (matchStaticOutput(
|
||||
self.decoded_static_route_params[index].getT(.string, "id"),
|
||||
|
@ -293,12 +293,13 @@ pub fn expectJob(job_name: []const u8, job_params: anytype, response: TestRespon
|
||||
return error.JetzigExpectJobError;
|
||||
}
|
||||
|
||||
// fn log(comptime message: []const u8, args: anytype) void {
|
||||
// std.log.info("[jetzig.testing] " ++ message ++ "\n", args);
|
||||
// }
|
||||
pub const File = struct { filename: []const u8, content: []const u8 };
|
||||
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 {
|
||||
std.log.err(message, args);
|
||||
std.log.err("[jetzig.testing] " ++ message, args);
|
||||
}
|
||||
|
||||
fn jsonPretty(response: TestResponse) ![]const u8 {
|
||||
|
@ -11,9 +11,11 @@ arena: *std.heap.ArenaAllocator,
|
||||
store: *jetzig.kv.Store,
|
||||
cache: *jetzig.kv.Store,
|
||||
job_queue: *jetzig.kv.Store,
|
||||
multipart_boundary: ?[]const u8 = null,
|
||||
|
||||
const initHook = jetzig.root.initHook;
|
||||
|
||||
/// Initialize a new test app.
|
||||
pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
|
||||
switch (jetzig.testing.state) {
|
||||
.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 {
|
||||
self.arena.deinit();
|
||||
self.allocator.destroy(self.arena);
|
||||
@ -48,6 +51,21 @@ const RequestOptions = struct {
|
||||
headers: []const jetzig.testing.TestResponse.Header = &.{},
|
||||
json: ?[]const u8 = 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 {
|
||||
@ -55,6 +73,7 @@ const Param = struct {
|
||||
value: ?[]const u8,
|
||||
};
|
||||
|
||||
/// Issue a request to the test server.
|
||||
pub fn request(
|
||||
self: *App,
|
||||
comptime method: jetzig.http.Request.Method,
|
||||
@ -93,7 +112,7 @@ pub fn request(
|
||||
try server.decodeStaticParams();
|
||||
|
||||
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);
|
||||
try server.processNextRequest(&httpz_request, &httpz_response);
|
||||
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 {
|
||||
const allocator = self.arena.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");
|
||||
}
|
||||
|
||||
/// Encode an arbitrary struct to a JSON string for use as a request body.
|
||||
pub fn json(self: App, args: anytype) []const u8 {
|
||||
const allocator = self.arena.allocator();
|
||||
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(
|
||||
allocator: std.mem.Allocator,
|
||||
buf: []u8,
|
||||
comptime method: jetzig.http.Request.Method,
|
||||
comptime path: []const u8,
|
||||
multipart_boundary: ?[]const u8,
|
||||
options: RequestOptions,
|
||||
) !httpz.Request {
|
||||
var request_headers = try keyValue(allocator, 32);
|
||||
@ -148,6 +213,9 @@ fn stubbedRequest(
|
||||
if (options.json != null) {
|
||||
request_headers.add("accept", "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);
|
||||
@ -174,8 +242,11 @@ fn stubbedRequest(
|
||||
.protocol = .HTTP11,
|
||||
.params = undefined,
|
||||
.headers = request_headers,
|
||||
.body_buffer = if (options.json) |capture| .{ .data = @constCast(capture), .type = .static } else null,
|
||||
.body_len = if (options.json) |capture| capture.len else 0,
|
||||
.body_buffer = if (options.getBody()) |capture|
|
||||
.{ .data = @constCast(capture), .type = .static }
|
||||
else
|
||||
null,
|
||||
.body_len = options.bodyLen(),
|
||||
.qs = try keyValue(allocator, 32),
|
||||
.fd = try keyValue(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, "json")) 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);
|
||||
@ -234,5 +306,6 @@ fn buildOptions(app: *const App, args: anytype) RequestOptions {
|
||||
.headers = if (@hasField(@TypeOf(args), "headers")) args.headers else &.{},
|
||||
.json = if (@hasField(@TypeOf(args), "json")) app.json(args.json) else null,
|
||||
.params = if (@hasField(@TypeOf(args), "params")) app.params(args.params) else null,
|
||||
.body = if (@hasField(@TypeOf(args), "body")) args.body else null,
|
||||
};
|
||||
}
|
||||
|
@ -69,6 +69,16 @@ pub fn generateSecret(allocator: std.mem.Allocator, comptime len: u10) ![]const
|
||||
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.
|
||||
pub fn duration(start_time: i128) i64 {
|
||||
return @intCast(std.time.nanoTimestamp() - start_time);
|
||||
|
Loading…
x
Reference in New Issue
Block a user