diff --git a/build.zig.zon b/build.zig.zon index 4cb7c84..6beb660 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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", }, }, diff --git a/demo/src/app/views/file_upload.zig b/demo/src/app/views/file_upload.zig new file mode 100644 index 0000000..4dc73f5 --- /dev/null +++ b/demo/src/app/views/file_upload.zig @@ -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"); +} diff --git a/demo/src/app/views/file_upload/index.zmpl b/demo/src/app/views/file_upload/index.zmpl new file mode 100644 index 0000000..9ba00bb --- /dev/null +++ b/demo/src/app/views/file_upload/index.zmpl @@ -0,0 +1,7 @@ +
diff --git a/demo/src/app/views/file_upload/post.zmpl b/demo/src/app/views/file_upload/post.zmpl new file mode 100644 index 0000000..6e13d98 --- /dev/null +++ b/demo/src/app/views/file_upload/post.zmpl @@ -0,0 +1,10 @@ +{{.description}}
+ +{{.filename}}
+ +{{.content}}
diff --git a/demo/src/main.zig b/demo/src/main.zig index fa5b8e9..0d83d30 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -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; diff --git a/src/jetzig.zig b/src/jetzig.zig index 75cc24d..49d58b5 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -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; diff --git a/src/jetzig/http.zig b/src/jetzig/http.zig index 89014b8..03c21e2 100644 --- a/src/jetzig/http.zig +++ b/src/jetzig/http.zig @@ -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; diff --git a/src/jetzig/http/File.zig b/src/jetzig/http/File.zig new file mode 100644 index 0000000..7762304 --- /dev/null +++ b/src/jetzig/http/File.zig @@ -0,0 +1,4 @@ +const std = @import("std"); + +filename: []const u8, +content: []const u8, diff --git a/src/jetzig/http/MultipartQuery.zig b/src/jetzig/http/MultipartQuery.zig new file mode 100644 index 0000000..a780069 --- /dev/null +++ b/src/jetzig/http/MultipartQuery.zig @@ -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; +} diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 683f68c..70a56ee 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -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.?; } diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index e2d6976..e6b0337 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -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"), diff --git a/src/jetzig/testing.zig b/src/jetzig/testing.zig index 368db2c..d36b893 100644 --- a/src/jetzig/testing.zig +++ b/src/jetzig/testing.zig @@ -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 { diff --git a/src/jetzig/testing/App.zig b/src/jetzig/testing/App.zig index e6d8446..bf35cc7 100644 --- a/src/jetzig/testing/App.zig +++ b/src/jetzig/testing/App.zig @@ -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, }; } diff --git a/src/jetzig/util.zig b/src/jetzig/util.zig index b1420f8..1e8994e 100644 --- a/src/jetzig/util.zig +++ b/src/jetzig/util.zig @@ -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);