From 887e4e551b75b1cae9606fb6c708efe11d75fbc1 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Mon, 27 May 2024 16:35:05 +0100 Subject: [PATCH] Middleware request resolution Allow middleware to resolve a request by calling `request.redirect` or `request.render` - after this point, the request stops processing and renders immediately. --- demo/src/app/middleware/DemoMiddleware.zig | 6 +++ demo/src/app/views/301.zmpl | 1 + demo/src/main.zig | 38 ++++++++++----- src/jetzig.zig | 13 +++-- src/jetzig/colors.zig | 2 +- src/jetzig/http.zig | 4 ++ src/jetzig/http/Request.zig | 55 +++++++++++++++++---- src/jetzig/http/Server.zig | 57 ++++++++++++++-------- src/jetzig/http/middleware.zig | 46 +++++++++++------ src/jetzig/loggers/DevelopmentLogger.zig | 12 ++++- src/jetzig/middleware/HtmxMiddleware.zig | 2 +- 11 files changed, 173 insertions(+), 63 deletions(-) create mode 100644 demo/src/app/views/301.zmpl diff --git a/demo/src/app/middleware/DemoMiddleware.zig b/demo/src/app/middleware/DemoMiddleware.zig index d390535..b1d746d 100644 --- a/demo/src/app/middleware/DemoMiddleware.zig +++ b/demo/src/app/middleware/DemoMiddleware.zig @@ -33,6 +33,12 @@ pub fn init(request: *jetzig.http.Request) !*DemoMiddleware { /// Any calls to `request.render` or `request.redirect` will prevent further processing of the /// request, including any other middleware in the chain. pub fn afterRequest(self: *DemoMiddleware, request: *jetzig.http.Request) !void { + // Middleware can invoke `request.redirect` or `request.render`. All request processing stops + // and the response is immediately returned if either of these two functions are called + // during middleware processing. + // _ = request.redirect("/foobar", .moved_permanently); + // _ = request.render(.unauthorized); + try request.server.logger.DEBUG( "[DemoMiddleware:afterRequest] my_custom_value: {s}", .{self.my_custom_value}, diff --git a/demo/src/app/views/301.zmpl b/demo/src/app/views/301.zmpl new file mode 100644 index 0000000..236f650 --- /dev/null +++ b/demo/src/app/views/301.zmpl @@ -0,0 +1 @@ +Redirecting to {{.location}} diff --git a/demo/src/main.zig b/demo/src/main.zig index df13950..d22356f 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -16,39 +16,55 @@ pub const jetzig_options = struct { jetzig.middleware.HtmxMiddleware, // Demo middleware included with new projects. Remove once you are familiar with Jetzig's // middleware system. - // @import("app/middleware/DemoMiddleware.zig"), + @import("app/middleware/DemoMiddleware.zig"), }; // Maximum bytes to allow in request body. - // pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 16); + pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 16); // Maximum filesize for `public/` content. - // pub const max_bytes_public_content: usize = std.math.pow(usize, 2, 20); + pub const max_bytes_public_content: usize = std.math.pow(usize, 2, 20); // Maximum filesize for `static/` content (applies only to apps using `jetzig.http.StaticRequest`). - // pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 18); + pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 18); // Maximum length of a header name. There is no limit imposed by the HTTP specification but // AWS load balancers reference 40 as a limit so we use that as a baseline: // https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_HttpHeaderConditionConfig.html // This can be increased if needed. - // pub const max_bytes_header_name: u16 = 40; + pub const max_bytes_header_name: u16 = 40; // 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; + pub const log_message_buffer_len: usize = 4096; // Maximum log pool size. When a log buffer is no longer required it is returned to a pool // for recycling. When logging i/o is slow, a high volume of requests will result in this // pool growing. When the pool size reaches the maximum value defined here, log events are // freed instead of recycled. - // pub const max_log_pool_len: usize = 256; + pub const max_log_pool_len: usize = 256; + + // Number of request threads. Defaults to number of detected CPUs. + pub const thread_count: ?u16 = null; + + // Number of response worker threads. + pub const worker_count: u16 = 4; + + // Total number of connections managed by worker threads. + pub const max_connections: u16 = 512; + + // Per-thread stack memory to use before spilling into request arena (possibly with allocations). + pub const buffer_size: usize = 64 * 1024; + + // The size of each item in the available memory pool used by requests for rendering. + // Total retained allocation: `worker_count * max_connections`. + pub const arena_size: usize = 1024 * 1024; // Path relative to cwd() to serve public content from. Symlinks are not followed. - // pub const public_content_path = "public"; + pub const public_content_path = "public"; // HTTP buffer. Must be large enough to store all headers. This should typically not be modified. - // pub const http_buffer_size: usize = std.math.pow(usize, 2, 16); + pub const http_buffer_size: usize = std.math.pow(usize, 2, 16); // The number of worker threads to spawn on startup for processing Jobs (NOT the number of // HTTP server worker threads). @@ -56,7 +72,7 @@ pub const jetzig_options = struct { // Duration before looking for more Jobs when the queue is found to be empty, in // milliseconds. - // pub const job_worker_sleep_interval_ms: usize = 10; + pub const job_worker_sleep_interval_ms: usize = 10; /// Key-value store options. Set backend to `.file` to use a file-based store. /// When using `.file` backend, you must also set `.file_options`. @@ -108,7 +124,7 @@ pub const jetzig_options = struct { // }; /// Force email delivery in development mode (instead of printing email body to logger). - // pub const force_development_email_delivery = false; + pub const force_development_email_delivery = false; // Set custom fragments for rendering markdown templates. Any values will fall back to // defaults provided by Zmd (https://github.com/jetzig-framework/zmd/blob/main/src/zmd/html.zig). diff --git a/src/jetzig.zig b/src/jetzig.zig index 2ce95ec..e55c248 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -101,16 +101,21 @@ pub const config = struct { /// Number of request threads. Defaults to number of detected CPUs. pub const thread_count: ?u16 = null; + /// Per-thread stack memory to use before spilling into request arena (possibly with allocations). + pub const buffer_size: usize = 64 * 1024; + + /// The pre-heated size of each item in the available memory pool used by requests for + /// rendering. Total retained allocation: `worker_count * max_connections`. Requests + /// requiring more memory will allocate per-request, leaving `arena_size` bytes pre-allocated + /// for the next request. + pub const arena_size: usize = 1024 * 1024; + /// Number of response worker threads. pub const worker_count: u16 = 4; /// Total number of connections managed by worker threads. pub const max_connections: u16 = 512; - /// The size of each item in the available memory pool used by requests for rendering. - /// Total retained allocation: `worker_count * max_connections`. - pub const arena_size: usize = 1024 * 1024; - /// Path relative to cwd() to serve public content from. Symlinks are not followed. pub const public_content_path = "public"; diff --git a/src/jetzig/colors.zig b/src/jetzig/colors.zig index 5d74026..4a4f14d 100644 --- a/src/jetzig/colors.zig +++ b/src/jetzig/colors.zig @@ -5,7 +5,7 @@ const builtin = @import("builtin"); const types = @import("types.zig"); // Must be consistent with `std.io.tty.Color` for Windows compatibility. -const codes = .{ +pub const codes = .{ .escape = "\x1b[", .black = "30m", .red = "31m", diff --git a/src/jetzig/http.zig b/src/jetzig/http.zig index e35f325..89014b8 100644 --- a/src/jetzig/http.zig +++ b/src/jetzig/http.zig @@ -14,3 +14,7 @@ pub const status_codes = @import("http/status_codes.zig"); pub const StatusCode = status_codes.StatusCode; pub const middleware = @import("http/middleware.zig"); pub const mime = @import("http/mime.zig"); + +pub const SimplifiedRequest = struct { + location: ?[]const u8, +}; diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 416fe71..9bf1379 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -26,12 +26,16 @@ query_body: ?*jetzig.http.Query = null, _cookies: ?*jetzig.http.Cookies = null, _session: ?*jetzig.http.Session = null, body: []const u8 = undefined, -processed: bool = false, +state: enum { initial, processed } = .initial, +response_started: bool = false, dynamic_assigned_template: ?[]const u8 = null, layout: ?[]const u8 = null, layout_disabled: bool = false, rendered: bool = false, redirected: bool = false, +redirect_state: ?RedirectState = null, +middleware_rendered: ?struct { name: []const u8, action: []const u8 } = null, +middleware_rendered_during_response: bool = false, rendered_multiple: bool = false, rendered_view: ?jetzig.views.View = null, start_time: i128, @@ -132,18 +136,18 @@ pub fn deinit(self: *Request) void { self.cookies.deinit(); self.allocator.destroy(self.cookies); self.allocator.destroy(self.session); - if (self.processed) self.allocator.free(self.body); + if (self.state != .initial) self.allocator.free(self.body); } /// Process request, read body if present. pub fn process(self: *Request) !void { self.body = self.httpz_request.body() orelse ""; - self.processed = true; + self.state = .processed; } /// Set response headers, write response payload, and finalize the response. pub fn respond(self: *Request) !void { - if (!self.processed) unreachable; + if (self.state == .initial) unreachable; try self.setCookieHeaders(); @@ -158,6 +162,7 @@ pub fn render(self: *Request, status_code: jetzig.http.status_codes.StatusCode) if (self.rendered) self.rendered_multiple = true; self.rendered = true; + if (self.response_started) self.middleware_rendered_during_response = true; self.rendered_view = .{ .data = self.response_data, .status_code = status_code }; return self.rendered_view.?; } @@ -179,15 +184,23 @@ pub fn redirect( self.rendered = true; self.redirected = true; + if (self.response_started) self.middleware_rendered_during_response = true; const status_code = switch (redirect_status) { .moved_permanently => jetzig.http.status_codes.StatusCode.moved_permanently, .found => jetzig.http.status_codes.StatusCode.found, }; + self.redirect_state = .{ .location = location, .status_code = status_code }; + return .{ .data = self.response_data, .status_code = status_code }; +} + +const RedirectState = struct { location: []const u8, status_code: jetzig.http.status_codes.StatusCode }; + +pub fn renderRedirect(self: *Request, state: RedirectState) !void { self.response_data.reset(); - self.response.headers.append("Location", location) catch |err| { + self.response.headers.append("Location", state.location) catch |err| { switch (err) { error.JetzigTooManyHeaders => std.debug.print( "Header limit reached. Unable to add redirect header.\n", @@ -197,8 +210,32 @@ pub fn redirect( } }; - self.rendered_view = .{ .data = self.response_data, .status_code = status_code }; - return self.rendered_view.?; + const view = .{ .data = self.response_data, .status_code = state.status_code }; + const status = jetzig.http.status_codes.get(state.status_code); + const maybe_template = jetzig.zmpl.findPrefixed("views", status.getCode()); + self.rendered_view = view; + + var root = try self.response_data.root(.object); + try root.put("location", self.response_data.string(state.location)); + const content = switch (self.requestFormat()) { + .HTML, .UNKNOWN => if (maybe_template) |template| blk: { + try view.data.addConst("jetzig_view", view.data.string("internal")); + try view.data.addConst("jetzig_action", view.data.string(@tagName(state.status_code))); + break :blk try template.render(self.response_data); + } else try std.fmt.allocPrint(self.allocator, "Redirecting to {s}", .{state.location}), + .JSON => blk: { + break :blk try std.json.stringifyAlloc( + self.allocator, + .{ .location = state.location, .status = .{ + .message = status.getMessage(), + .code = status.getCode(), + } }, + .{}, + ); + }, + }; + + self.setResponse(.{ .view = view, .content = content }, .{}); } /// Infer the current format (JSON or HTML) from the request in this order: @@ -246,7 +283,7 @@ pub fn getHeader(self: *const Request, key: []const u8) ?[]const u8 { /// otherwise the parsed JSON request body will take precedence and query parameters will be /// ignored. pub fn params(self: *Request) !*jetzig.data.Value { - if (!self.processed) unreachable; + if (self.state == .initial) unreachable; switch (self.requestFormat()) { .JSON => { @@ -489,7 +526,7 @@ pub fn formatStatus(self: *const Request, status_code: jetzig.http.StatusCode) ! return switch (self.requestFormat()) { .JSON => try std.json.stringifyAlloc(self.allocator, .{ - .@"error" = .{ + .status = .{ .message = status.getMessage(), .code = status.getCode(), }, diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 413854f..82918e8 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -70,7 +70,7 @@ const Dispatcher = struct { pub fn handle(self: Dispatcher, request: *httpz.Request, response: *httpz.Response) void { self.server.processNextRequest(request, response) catch |err| { - self.server.errorHandlerFn(request, response, err); + self.server.errorHandlerFn(request, response, err) catch {}; }; } }; @@ -83,6 +83,7 @@ pub fn listen(self: *Server) !void { .address = self.options.bind, .thread_pool = .{ .count = jetzig.config.get(?u16, "thread_count") orelse @intCast(try std.Thread.getCpuCount()), + .buffer_size = jetzig.config.get(usize, "buffer_size"), }, .workers = .{ .count = jetzig.config.get(u16, "worker_count"), @@ -105,10 +106,13 @@ pub fn listen(self: *Server) !void { return try httpz_server.listen(); } -pub fn errorHandlerFn(self: *Server, request: *httpz.Request, response: *httpz.Response, err: anyerror) void { +pub fn errorHandlerFn(self: *Server, request: *httpz.Request, response: *httpz.Response, err: anyerror) !void { if (isBadHttpError(err)) return; self.logger.ERROR("Encountered error: {s} {s}", .{ @errorName(err), request.url.raw }) catch {}; + const stack = @errorReturnTrace(); + if (stack) |capture| self.logStackTrace(capture, .{ .httpz = request }) catch {}; + response.body = "500 Internal Server Error"; } @@ -119,11 +123,9 @@ fn processNextRequest( ) !void { const start_time = std.time.nanoTimestamp(); - const allocator = httpz_request.arena; - - var response = try jetzig.http.Response.init(allocator, httpz_response); + var response = try jetzig.http.Response.init(httpz_response.arena, httpz_response); var request = try jetzig.http.Request.init( - allocator, + httpz_request.arena, self, start_time, httpz_request, @@ -135,15 +137,24 @@ fn processNextRequest( var middleware_data = try jetzig.http.middleware.afterRequest(&request); - try self.renderResponse(&request); - try request.response.headers.append("Content-Type", response.content_type); + if (request.middleware_rendered) |_| { // Request processing ends when a middleware renders or redirects. + if (request.redirect_state) |state| { + try request.renderRedirect(state); + } else if (request.rendered_view) |rendered| { + // TODO: Allow middleware to set content + request.setResponse(.{ .view = rendered, .content = "" }, .{}); + } + try request.response.headers.append("Content-Type", response.content_type); + try request.respond(); + } else { + try self.renderResponse(&request); + try request.response.headers.append("Content-Type", response.content_type); + try jetzig.http.middleware.beforeResponse(&middleware_data, &request); + jetzig.http.middleware.deinit(&middleware_data, &request); - try jetzig.http.middleware.beforeResponse(&middleware_data, &request); - - try request.respond(); - - try jetzig.http.middleware.afterResponse(&middleware_data, &request); - jetzig.http.middleware.deinit(&middleware_data, &request); + try jetzig.http.middleware.afterResponse(&middleware_data, &request); + try request.respond(); + } try self.logger.logRequest(&request); } @@ -180,6 +191,8 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void { .JSON => try self.renderJSON(request, route), .UNKNOWN => try self.renderHTML(request, route), } + + if (request.redirect_state) |state| return try request.renderRedirect(state); } fn renderStatic(resource: StaticResource, request: *jetzig.http.Request) !void { @@ -388,7 +401,7 @@ fn renderInternalServerError(self: *Server, request: *jetzig.http.Request, err: try self.logger.ERROR("Encountered Error: {s}", .{@errorName(err)}); const stack = @errorReturnTrace(); - if (stack) |capture| try self.logStackTrace(capture, request); + if (stack) |capture| try self.logStackTrace(capture, .{ .jetzig = request }); const status = .internal_server_error; return try self.renderError(request, status); @@ -436,7 +449,7 @@ fn renderErrorView( .{@errorName(err)}, ); const stack = @errorReturnTrace(); - if (stack) |capture| try self.logStackTrace(capture, request); + if (stack) |capture| try self.logStackTrace(capture, .{ .jetzig = request }); return try renderDefaultError(request, status_code); }; @@ -503,14 +516,18 @@ fn renderDefaultError( fn logStackTrace( self: Server, stack: *std.builtin.StackTrace, - request: *jetzig.http.Request, + request: union(enum) { jetzig: *const jetzig.http.Request, httpz: *const httpz.Request }, ) !void { - try self.logger.ERROR("\nStack Trace:\n{}", .{stack}); - var buf = std.ArrayList(u8).init(request.allocator); + const allocator = switch (request) { + .jetzig => |capture| capture.allocator, + .httpz => |capture| capture.arena, + }; + + var buf = std.ArrayList(u8).init(allocator); defer buf.deinit(); const writer = buf.writer(); try stack.format("", .{}, writer); - try self.logger.ERROR("{s}\n", .{buf.items}); + if (buf.items.len > 0) try self.logger.ERROR("{s}\n", .{buf.items}); } fn matchCustomRoute(self: Server, request: *const jetzig.http.Request) ?jetzig.views.Route { diff --git a/src/jetzig/http/middleware.zig b/src/jetzig/http/middleware.zig index bf28cf3..bead30f 100644 --- a/src/jetzig/http/middleware.zig +++ b/src/jetzig/http/middleware.zig @@ -3,22 +3,24 @@ const jetzig = @import("../../jetzig.zig"); const middlewares: []const type = jetzig.config.get([]const type, "middleware"); -const MiddlewareData = std.BoundedArray(*anyopaque, middlewares.len); +const MiddlewareData = std.BoundedArray(?*anyopaque, middlewares.len); pub fn afterRequest(request: *jetzig.http.Request) !MiddlewareData { var middleware_data = MiddlewareData.init(0) catch unreachable; inline for (middlewares, 0..) |middleware, index| { - if (comptime !@hasDecl(middleware, "init")) continue; - const data = try @call(.always_inline, middleware.init, .{request}); - // We cannot overflow here because we know the length of the array - middleware_data.insert(index, data) catch unreachable; + if (comptime !@hasDecl(middleware, "init")) { + try middleware_data.insert(index, null); + } else { + const data = try @call(.always_inline, middleware.init, .{request}); + try middleware_data.insert(index, data); + } } inline for (middlewares, 0..) |middleware, index| { if (comptime !@hasDecl(middleware, "afterRequest")) continue; if (comptime @hasDecl(middleware, "init")) { - const data = middleware_data.get(index); + const data = middleware_data.get(index).?; try @call( .always_inline, middleware.afterRequest, @@ -27,6 +29,10 @@ pub fn afterRequest(request: *jetzig.http.Request) !MiddlewareData { } else { try @call(.always_inline, middleware.afterRequest, .{request}); } + if (request.rendered or request.redirected) { + request.middleware_rendered = .{ .name = @typeName(middleware), .action = "afterRequest" }; + break; + } } return middleware_data; @@ -36,17 +42,25 @@ pub fn beforeResponse( middleware_data: *MiddlewareData, request: *jetzig.http.Request, ) !void { + request.response_started = true; + inline for (middlewares, 0..) |middleware, index| { if (comptime !@hasDecl(middleware, "beforeResponse")) continue; - if (comptime @hasDecl(middleware, "init")) { - const data = middleware_data.get(index); - try @call( - .always_inline, - middleware.beforeResponse, - .{ @as(*middleware, @ptrCast(@alignCast(data))), request, request.response }, - ); - } else { - try @call(.always_inline, middleware.beforeResponse, .{ request, request.response }); + if (!request.middleware_rendered_during_response) { + if (comptime @hasDecl(middleware, "init")) { + const data = middleware_data.get(index).?; + try @call( + .always_inline, + middleware.beforeResponse, + .{ @as(*middleware, @ptrCast(@alignCast(data))), request, request.response }, + ); + } else { + try @call(.always_inline, middleware.beforeResponse, .{ request, request.response }); + } + } + if (request.middleware_rendered_during_response) { + request.middleware_rendered = .{ .name = @typeName(middleware), .action = "beforeResponse" }; + break; } } } @@ -58,7 +72,7 @@ pub fn afterResponse( inline for (middlewares, 0..) |middleware, index| { if (comptime !@hasDecl(middleware, "afterResponse")) continue; if (comptime @hasDecl(middleware, "init")) { - const data = middleware_data.get(index); + const data = middleware_data.get(index).?; try @call( .always_inline, middleware.afterResponse, diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig index 3d3e722..19e6e7b 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -84,12 +84,22 @@ pub fn logRequest(self: DevelopmentLogger, request: *const jetzig.http.Request) const formatted_level = if (self.stdout_colorized) colorizedLogLevel(.INFO) else @tagName(.INFO); - try self.log_queue.print("{s: >5} [{s}] [{s}/{s}/{s}] {s}\n", .{ + try self.log_queue.print("{s: >5} [{s}] [{s}/{s}/{s}]{s}{s}{s}{s}{s}{s}{s}{s}{s}{s} {s}\n", .{ formatted_level, iso8601, formatted_duration, request.fmtMethod(self.stdout_colorized), formatted_status, + if (request.middleware_rendered) |_| " [" ++ jetzig.colors.codes.escape ++ jetzig.colors.codes.magenta else "", + if (request.middleware_rendered) |middleware| middleware.name else "", + if (request.middleware_rendered) |_| jetzig.colors.codes.escape ++ jetzig.colors.codes.white ++ ":" else "", + if (request.middleware_rendered) |_| jetzig.colors.codes.escape ++ jetzig.colors.codes.blue else "", + if (request.middleware_rendered) |middleware| middleware.action else "", + if (request.middleware_rendered) |_| jetzig.colors.codes.escape ++ jetzig.colors.codes.white ++ ":" else "", + if (request.middleware_rendered) |_| jetzig.colors.codes.escape ++ jetzig.colors.codes.bright_cyan else "", + if (request.middleware_rendered) |_| if (request.redirected) "redirect" else "render" else "", + if (request.middleware_rendered) |_| jetzig.colors.codes.escape ++ jetzig.colors.codes.reset else "", + if (request.middleware_rendered) |_| "]" else "", request.path.path, }, .stdout); } diff --git a/src/jetzig/middleware/HtmxMiddleware.zig b/src/jetzig/middleware/HtmxMiddleware.zig index 0f80448..022d010 100644 --- a/src/jetzig/middleware/HtmxMiddleware.zig +++ b/src/jetzig/middleware/HtmxMiddleware.zig @@ -19,7 +19,7 @@ pub fn afterRequest(request: *jetzig.http.Request) !void { /// If a redirect was issued during request processing, reset any response data, set response /// status to `200 OK` and replace the `Location` header with a `HX-Redirect` header. -pub fn beforeResponse(request: *const jetzig.http.Request, response: *jetzig.http.Response) !void { +pub fn beforeResponse(request: *jetzig.http.Request, response: *jetzig.http.Response) !void { if (response.status_code != .moved_permanently and response.status_code != .found) return; if (request.headers.get("HX-Request") == null) return;