This commit is contained in:
Bob Farrell 2024-05-08 17:53:48 +01:00
parent 1693f24ce2
commit 35c5f26de9
4 changed files with 5 additions and 558 deletions

View File

@ -23,8 +23,8 @@
.hash = "1220d4f1c2472769b0d689ea878f41f0a66cb07f28569a138aea2c0a648a5c90dd4e",
},
.httpz = .{
.url = "https://github.com/karlseguin/http.zig/archive/0d4a5cd520a54eaf800438e0b9093c77c90dcf11.tar.gz",
.hash = "12209b8216a80f21be12d43e588811150bdbbb53d35eac6a2a61c460f197350e19ad",
.url = "https://github.com/karlseguin/http.zig/archive/206a34c0ee35a07b89d000f630b2f1e0f7c98119.tar.gz",
.hash = "1220768b5925b4e13f73c036f1ca18b4a7d987ffaf5e825af6443d5d4ed8e37e7dfd",
},
},

View File

@ -2,10 +2,7 @@ const std = @import("std");
const builtin = @import("builtin");
pub const Server = @import("http/Server.zig");
pub const Request = if (builtin.os.tag == .windows)
@import("windows/Request.zig")
else
@import("http/Request.zig");
pub const Request = @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");

View File

@ -72,8 +72,6 @@ const Dispatcher = struct {
};
pub fn listen(self: *Server) !void {
if (builtin.os.tag == .windows) return try @import("../windows.zig").listen(self);
var httpz_server = try httpz.ServerCtx(Dispatcher, Dispatcher).init(
self.allocator,
.{
@ -150,8 +148,7 @@ fn processNextRequest(
try self.logger.logRequest(&request);
}
// TODO: Make private when http.zig Windows is working
pub fn renderResponse(self: *Server, request: *jetzig.http.Request) !void {
fn renderResponse(self: *Server, request: *jetzig.http.Request) !void {
const static_resource = self.matchStaticResource(request) catch |err| {
if (isUnhandledError(err)) return err;
@ -332,8 +329,7 @@ fn isUnhandledError(err: anyerror) bool {
};
}
// TODO: Make private when http.zig Windows is working
pub fn isBadHttpError(err: anyerror) bool {
fn isBadHttpError(err: anyerror) bool {
return switch (err) {
error.JetzigParseHeadError,
error.UnknownHttpMethod,

View File

@ -1,546 +0,0 @@
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/<name>.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);
}