diff --git a/src/jetzig/http.zig b/src/jetzig/http.zig index 5151d26..f558717 100644 --- a/src/jetzig/http.zig +++ b/src/jetzig/http.zig @@ -1,7 +1,11 @@ const std = @import("std"); +const builtin = @import("builtin"); pub const Server = @import("http/Server.zig"); -pub const Request = @import("http/Request.zig"); +pub const Request = if (builtin.os.tag == .windows) + @import("http/RequestWindows.zig") +else + @import("http/Request.zig"); pub const StaticRequest = @import("http/StaticRequest.zig"); pub const Response = @import("http/Response.zig"); pub const Session = @import("http/Session.zig"); diff --git a/src/jetzig/http/RequestWindows.zig b/src/jetzig/http/RequestWindows.zig new file mode 100644 index 0000000..e260a06 --- /dev/null +++ b/src/jetzig/http/RequestWindows.zig @@ -0,0 +1,546 @@ +const std = @import("std"); + +const jetzig = @import("../../jetzig.zig"); + +const Request = @This(); +const default_content_type = "text/html"; + +pub const Method = enum { DELETE, GET, PATCH, POST, HEAD, PUT, CONNECT, OPTIONS, TRACE }; +pub const Modifier = enum { edit, new }; +pub const Format = enum { HTML, JSON, UNKNOWN }; + +allocator: std.mem.Allocator, +path: jetzig.http.Path, +method: Method, +headers: jetzig.http.Headers, +server: *jetzig.http.Server, +std_http_request: std.http.Server.Request, +response: *jetzig.http.Response, +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, +cookies: *jetzig.http.Cookies = undefined, +session: *jetzig.http.Session = undefined, +body: []const u8 = undefined, +processed: bool = false, +layout: ?[]const u8 = null, +layout_disabled: bool = false, +rendered: bool = false, +redirected: bool = false, +rendered_multiple: bool = false, +rendered_view: ?jetzig.views.View = null, +start_time: i128, +store: RequestStore, +cache: RequestStore, + +/// Wrapper for KV store that uses the request's arena allocator for fetching values. +pub const RequestStore = struct { + allocator: std.mem.Allocator, + store: *jetzig.kv.Store, + + /// Put a String or into the key-value store. + pub fn get(self: RequestStore, key: []const u8) !?*jetzig.data.Value { + return try self.store.get(try self.data(), key); + } + + /// Get a String from the store. + pub fn put(self: RequestStore, key: []const u8, value: *jetzig.data.Value) !void { + try self.store.put(key, value); + } + + /// Remove a String to from the key-value store and return it if found. + pub fn fetchRemove(self: RequestStore, key: []const u8) !?*jetzig.data.Value { + return try self.store.fetchRemove(try self.data(), key); + } + + /// Remove a String to from the key-value store. + pub fn remove(self: RequestStore, key: []const u8) !void { + try self.store.remove(key); + } + + /// Append a Value to the end of an Array in the key-value store. + pub fn append(self: RequestStore, key: []const u8, value: *jetzig.data.Value) !void { + try self.store.append(key, value); + } + + /// Prepend a Value to the start of an Array in the key-value store. + pub fn prepend(self: RequestStore, key: []const u8, value: *jetzig.data.Value) !void { + try self.store.prepend(key, value); + } + + /// Pop a String from an Array in the key-value store. + pub fn pop(self: RequestStore, key: []const u8) !?*jetzig.data.Value { + return try self.store.pop(try self.data(), key); + } + + /// Left-pop a String from an Array in the key-value store. + pub fn popFirst(self: RequestStore, key: []const u8) !?*jetzig.data.Value { + return try self.store.popFirst(try self.data(), key); + } + + fn data(self: RequestStore) !*jetzig.data.Data { + const arena_data = try self.allocator.create(jetzig.data.Data); + arena_data.* = jetzig.data.Data.init(self.allocator); + return arena_data; + } +}; + +pub fn init( + allocator: std.mem.Allocator, + server: *jetzig.http.Server, + start_time: i128, + std_http_request: std.http.Server.Request, + response: *jetzig.http.Response, +) !Request { + const method = switch (std_http_request.head.method) { + .DELETE => Method.DELETE, + .GET => Method.GET, + .PATCH => Method.PATCH, + .POST => Method.POST, + .HEAD => Method.HEAD, + .PUT => Method.PUT, + .CONNECT => Method.CONNECT, + .OPTIONS => Method.OPTIONS, + .TRACE => Method.TRACE, + _ => return error.JetzigUnsupportedHttpMethod, + }; + + const response_data = try allocator.create(jetzig.data.Data); + response_data.* = jetzig.data.Data.init(allocator); + + return .{ + .allocator = allocator, + .path = jetzig.http.Path.init(std_http_request.head.target), + .method = method, + .headers = jetzig.http.Headers.init(allocator), + .server = server, + .response = response, + .response_data = response_data, + .std_http_request = std_http_request, + .start_time = start_time, + .store = .{ .store = server.store, .allocator = allocator }, + .cache = .{ .store = server.cache, .allocator = allocator }, + }; +} + +pub fn deinit(self: *Request) void { + self.session.deinit(); + self.cookies.deinit(); + self.allocator.destroy(self.cookies); + self.allocator.destroy(self.session); + if (self.processed) self.allocator.free(self.body); +} + +/// Process request, read body if present, parse headers (TODO) +pub fn process(self: *Request) !void { + var headers_it = self.std_http_request.iterateHeaders(); + var cookie: ?[]const u8 = null; + + while (headers_it.next()) |header| { + try self.headers.append(header.name, header.value); + if (std.mem.eql(u8, header.name, "Cookie")) cookie = header.value; + } + + self.cookies = try self.allocator.create(jetzig.http.Cookies); + self.cookies.* = jetzig.http.Cookies.init( + self.allocator, + cookie orelse "", + ); + try self.cookies.parse(); + + self.session = try self.allocator.create(jetzig.http.Session); + self.session.* = jetzig.http.Session.init(self.allocator, self.cookies, self.server.options.secret); + self.session.parse() catch |err| { + switch (err) { + error.JetzigInvalidSessionCookie => { + try self.server.logger.DEBUG("Invalid session cookie detected. Resetting session.", .{}); + try self.session.reset(); + }, + else => return err, + } + }; + + const reader = try self.std_http_request.reader(); + self.body = try reader.readAllAlloc(self.allocator, jetzig.config.get(usize, "max_bytes_request_body")); + self.processed = true; +} + +/// Set response headers, write response payload, and finalize the response. +pub fn respond(self: *Request) !void { + if (!self.processed) unreachable; + + var cookie_it = self.cookies.headerIterator(); + while (try cookie_it.next()) |header| { + // FIXME: Skip setting cookies that are already present ? + try self.response.headers.append("Set-Cookie", header); + } + + var std_response_headers = try self.response.headers.stdHeaders(); + defer std_response_headers.deinit(self.allocator); + + try self.std_http_request.respond( + self.response.content, + .{ + .keep_alive = false, + .status = switch (self.response.status_code) { + inline else => |tag| @field(std.http.Status, @tagName(tag)), + }, + .extra_headers = std_response_headers.items, + }, + ); +} + +/// Render a response. This function can only be called once per request (repeat calls will +/// trigger an error). +pub fn render(self: *Request, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View { + if (self.rendered) self.rendered_multiple = true; + + self.rendered = true; + self.rendered_view = .{ .data = self.response_data, .status_code = status_code }; + return self.rendered_view.?; +} + +/// Issue a redirect to a new location. +/// ```zig +/// return request.redirect("https://www.example.com/", .moved_permanently); +/// ``` +/// ```zig +/// return request.redirect("https://www.example.com/", .found); +/// ``` +/// The second argument must be `moved_permanently` or `found`. +pub fn redirect( + self: *Request, + location: []const u8, + redirect_status: enum { moved_permanently, found }, +) jetzig.views.View { + if (self.rendered) self.rendered_multiple = true; + + self.rendered = true; + self.redirected = true; + + const status_code = switch (redirect_status) { + .moved_permanently => jetzig.http.status_codes.StatusCode.moved_permanently, + .found => jetzig.http.status_codes.StatusCode.found, + }; + + self.response_data.reset(); + + self.response.headers.remove("Location"); + self.response.headers.append("Location", location) catch @panic("OOM"); + + self.rendered_view = .{ .data = self.response_data, .status_code = status_code }; + return self.rendered_view.?; +} + +/// Infer the current format (JSON or HTML) from the request in this order: +/// * Extension (path ends in `.json` or `.html`) +/// * `Accept` header (`application/json` or `text/html`) +/// * `Content-Type` header (`application/json` or `text/html`) +/// * Fall back to default: HTML +pub fn requestFormat(self: *const Request) jetzig.http.Request.Format { + return self.extensionFormat() orelse + self.acceptHeaderFormat() orelse + self.contentTypeHeaderFormat() orelse + .UNKNOWN; +} + +/// Set the layout for the current request/response. Use this to override a `pub const layout` +/// declaration in a view, either in middleware or in a view function itself. +pub fn setLayout(self: *Request, layout: ?[]const u8) void { + if (layout) |layout_name| { + self.layout = layout_name; + self.layout_disabled = false; + } else { + self.layout_disabled = true; + } +} + +/// Derive a layout name from the current request if defined, otherwise from the route (if +/// defined). +pub fn getLayout(self: *Request, route: *jetzig.views.Route) ?[]const u8 { + if (self.layout_disabled) return null; + if (self.layout) |capture| return capture; + if (route.layout) |capture| return capture; + + return null; +} + +/// Shortcut for `request.headers.getFirstValue`. Returns the first matching value for a given +/// header name or `null` if not found. Header names are case-insensitive. +pub fn getHeader(self: *const Request, key: []const u8) ?[]const u8 { + return self.headers.getFirstValue(key); +} + +/// Return a `Value` representing request parameters. Parameters are normalized, meaning that +/// both the JSON request body and query parameters are accessed via the same interface. +/// Note that query parameters are supported for JSON requests if no request body is present, +/// 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; + + switch (self.requestFormat()) { + .JSON => { + if (self.body.len == 0) return self.queryParams(); + + var data = try self.allocator.create(jetzig.data.Data); + data.* = jetzig.data.Data.init(self.allocator); + data.fromJson(self.body) catch |err| { + switch (err) { + error.SyntaxError, error.UnexpectedEndOfInput => return error.JetzigBodyParseError, + else => return err, + } + }; + return data.value.?; + }, + .HTML, .UNKNOWN => return self.parseQuery(), + } +} + +/// 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 { + if (self.query_params) |parsed| return parsed.data.value.?; + + const data = try self.allocator.create(jetzig.data.Data); + data.* = jetzig.data.Data.init(self.allocator); + self.query_params = try self.allocator.create(jetzig.http.Query); + self.query_params.?.* = jetzig.http.Query.init( + self.allocator, + self.path.query orelse "", + data, + ); + try self.query_params.?.parse(); + return self.query_params.?.data.value.?; +} + +// Parses request body as params if present, otherwise delegates to `queryParams`. +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.?; + + const data = try self.allocator.create(jetzig.data.Data); + data.* = jetzig.data.Data.init(self.allocator); + self.query_body = try self.allocator.create(jetzig.http.Query); + self.query_body.?.* = jetzig.http.Query.init( + self.allocator, + self.body, + data, + ); + try self.query_body.?.parse(); + return self.query_body.?.data.value.?; +} + +/// Creates a new Job. Receives a job name which must resolve to `src/app/jobs/.zig` +/// Call `Job.put(...)` to set job params. +/// Call `Job.background()` to run the job outside of the request/response flow. +/// e.g.: +/// ``` +/// pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { +/// var job = try request.job("foo"); // Will invoke `process()` in `src/app/jobs/foo.zig` +/// try job.put("foo", data.string("bar")); +/// try job.background(); // Job added to queue and processed by job worker. +/// return request.render(.ok); +/// } +/// ``` +pub fn job(self: *Request, job_name: []const u8) !*jetzig.Job { + const background_job = try self.allocator.create(jetzig.Job); + background_job.* = jetzig.Job.init( + self.allocator, + self.server.store, + self.server.job_queue, + self.server.cache, + self.server.logger, + self.server.job_definitions, + job_name, + ); + return background_job; +} + +const RequestMail = struct { + request: *Request, + mail_params: jetzig.mail.MailParams, + name: []const u8, + + // Will allow scheduling when strategy is `.later` (e.g.). + const DeliveryOptions = struct {}; + + pub fn deliver(self: RequestMail, strategy: enum { background, now }, options: DeliveryOptions) !void { + _ = options; + var mail_job = try self.request.job("__jetzig_mail"); + + try mail_job.params.put("mailer_name", mail_job.data.string(self.name)); + + const from = if (self.mail_params.from) |from| mail_job.data.string(from) else null; + try mail_job.params.put("from", from); + + var to_array = try mail_job.data.array(); + if (self.mail_params.to) |capture| { + for (capture) |to| try to_array.append(mail_job.data.string(to)); + } + try mail_job.params.put("to", to_array); + + const subject = if (self.mail_params.subject) |subject| mail_job.data.string(subject) else null; + try mail_job.params.put("subject", subject); + + const html = if (self.mail_params.html) |html| mail_job.data.string(html) else null; + try mail_job.params.put("html", html); + + const text = if (self.mail_params.text) |text| mail_job.data.string(text) else null; + try mail_job.params.put("text", text); + + if (self.request.response_data.value) |value| try mail_job.params.put( + "params", + if (strategy == .now) try value.clone(self.request.allocator) else value, + ); + + switch (strategy) { + .background => try mail_job.schedule(), + .now => try mail_job.definition.?.runFn( + self.request.allocator, + mail_job.params, + jetzig.jobs.JobEnv{ + .environment = self.request.server.options.environment, + .logger = self.request.server.logger, + .routes = self.request.server.routes, + .mailers = self.request.server.mailer_definitions, + .jobs = self.request.server.job_definitions, + .store = self.request.server.store, + .cache = self.request.server.cache, + .mutex = undefined, + }, + ), + } + } +}; + +pub fn mail(self: *Request, name: []const u8, mail_params: jetzig.mail.MailParams) RequestMail { + return .{ + .request = self, + .name = name, + .mail_params = mail_params, + }; +} + +fn extensionFormat(self: *const Request) ?jetzig.http.Request.Format { + const extension = self.path.extension orelse return null; + if (std.mem.eql(u8, extension, ".html")) { + return .HTML; + } else if (std.mem.eql(u8, extension, ".json")) { + return .JSON; + } else { + return null; + } +} + +pub fn acceptHeaderFormat(self: *const Request) ?jetzig.http.Request.Format { + const acceptHeader = self.getHeader("Accept"); + + if (acceptHeader) |item| { + if (std.mem.eql(u8, item, "text/html")) return .HTML; + if (std.mem.eql(u8, item, "application/json")) return .JSON; + } + + return null; +} + +pub fn contentTypeHeaderFormat(self: *const Request) ?jetzig.http.Request.Format { + const acceptHeader = self.getHeader("content-type"); + + if (acceptHeader) |item| { + if (std.mem.eql(u8, item, "text/html")) return .HTML; + if (std.mem.eql(u8, item, "application/json")) return .JSON; + } + + return null; +} + +pub fn hash(self: *Request) ![]const u8 { + return try std.fmt.allocPrint( + self.allocator, + "{s}-{s}-{s}", + .{ @tagName(self.method), self.path, @tagName(self.requestFormat()) }, + ); +} + +pub fn fmtMethod(self: *const Request, colorized: bool) []const u8 { + if (!colorized) return @tagName(self.method); + + return switch (self.method) { + .GET => jetzig.colors.cyan("GET"), + .PUT => jetzig.colors.yellow("PUT"), + .PATCH => jetzig.colors.yellow("PATCH"), + .HEAD => jetzig.colors.white("HEAD"), + .POST => jetzig.colors.green("POST"), + .DELETE => jetzig.colors.red("DELETE"), + inline else => |method| jetzig.colors.white(@tagName(method)), + }; +} + +/// Format a status code appropriately for the current request format. +/// e.g. `.HTML` => `404 Not Found` +/// `.JSON` => `{ "message": "Not Found", "status": "404" }` +pub fn formatStatus(self: *const Request, status_code: jetzig.http.StatusCode) ![]const u8 { + const status = jetzig.http.status_codes.get(status_code); + + return switch (self.requestFormat()) { + .JSON => try std.json.stringifyAlloc(self.allocator, .{ + .@"error" = .{ + .message = status.getMessage(), + .code = status.getCode(), + }, + }, .{}), + .HTML, .UNKNOWN => status.getFormatted(.{ .linebreak = true }), + }; +} + +pub fn setResponse( + self: *Request, + rendered_view: jetzig.http.Server.RenderedView, + options: struct { content_type: ?[]const u8 = null }, +) void { + self.response.content = rendered_view.content; + self.response.status_code = rendered_view.view.status_code; + self.response.content_type = options.content_type orelse switch (self.requestFormat()) { + .HTML, .UNKNOWN => "text/html", + .JSON => "application/json", + }; +} + +// Determine if a given route matches the current request. +pub fn match(self: *Request, route: jetzig.views.Route) !bool { + return switch (self.method) { + .GET => switch (route.action) { + .index => self.isMatch(.exact, route), + .get => self.isMatch(.resource_id, route), + else => false, + }, + .POST => switch (route.action) { + .post => self.isMatch(.exact, route), + else => false, + }, + .PUT => switch (route.action) { + .put => self.isMatch(.resource_id, route), + else => false, + }, + .PATCH => switch (route.action) { + .patch => self.isMatch(.resource_id, route), + else => false, + }, + .DELETE => switch (route.action) { + .delete => self.isMatch(.resource_id, route), + else => false, + }, + .HEAD, .CONNECT, .OPTIONS, .TRACE => false, + }; +} + +fn isMatch(self: *Request, match_type: enum { exact, resource_id }, route: jetzig.views.Route) bool { + const path = switch (match_type) { + .exact => self.path.base_path, + .resource_id => self.path.directory, + }; + + return std.mem.eql(u8, path, route.uri_path); +} diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 9ea6dcb..242d975 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const jetzig = @import("../../jetzig.zig"); const zmpl = @import("zmpl"); @@ -71,6 +72,8 @@ const Dispatcher = struct { }; pub fn listen(self: *Server) !void { + if (builtin.os.tag == .windows) return try self.listenWindows(); + var httpz_server = try httpz.ServerCtx(Dispatcher, Dispatcher).init( self.allocator, .{ @@ -100,6 +103,73 @@ pub fn errorHandlerFn(self: *Server, request: *httpz.Request, response: *httpz.R response.body = "500 Internal Server Error"; } +// TODO: http.zig windows +pub fn listenWindows(self: *Server) !void { + const address = try std.net.Address.parseIp(self.options.bind, self.options.port); + self.std_net_server = try address.listen(.{ .reuse_port = true }); + + self.initialized = true; + + try self.logger.INFO("Listening on http://{s}:{} [{s}]", .{ + self.options.bind, + self.options.port, + @tagName(self.options.environment), + }); + try self.processRequestsWindows(); +} + +// TODO: http.zig windows +fn processRequestsWindows(self: *Server) !void { + while (true) { + var arena = std.heap.ArenaAllocator.init(self.allocator); + errdefer arena.deinit(); + const allocator = arena.allocator(); + + const connection = try self.std_net_server.accept(); + + var buf: [jetzig.config.get(usize, "http_buffer_size")]u8 = undefined; + var std_http_server = std.http.Server.init(connection, &buf); + errdefer std_http_server.connection.stream.close(); + + self.processNextRequestWindows(allocator, &std_http_server) catch |err| { + if (isBadHttpError(err)) { + std_http_server.connection.stream.close(); + continue; + } else return err; + }; + + std_http_server.connection.stream.close(); + arena.deinit(); + } +} + +// TODO: http.zig windows +fn processNextRequestWindows(self: *Server, allocator: std.mem.Allocator, std_http_server: *std.http.Server) !void { + const start_time = std.time.nanoTimestamp(); + + const std_http_request = try std_http_server.receiveHead(); + if (std_http_server.state == .receiving_head) return error.JetzigParseHeadError; + + var response = try jetzig.http.Response.init(allocator); + var request = try jetzig.http.Request.init(allocator, self, start_time, std_http_request, &response); + + try request.process(); + + var middleware_data = try jetzig.http.middleware.afterRequest(&request); + + try self.renderResponse(&request); + try request.response.headers.append("content-type", response.content_type); + + 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 self.logger.logRequest(&request); +} + fn processNextRequest( self: *Server, httpz_request: *httpz.Request,