diff --git a/.gitignore b/.gitignore index 75e0ab9..cfe9ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ TODO.md main +get/ +zig-out/ +zig-cache/ +*.core +src/app/views/**/*.compiled.zig +archive.tar.gz diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba77c77 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2023-2024 Robert Farrell + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b077585 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# :airplane::lizard: Jetzig + +_Jetzig_ is a web framework written in [Zig](https://ziglang.org) :lizard:. + +The framework is under active development and is currently in an alpha state. + +_Jetzig_ is an ambitious and opinionated web framework. It aims to provide a rich set of user-friendly tools for building modern web applications quickly. See the checklist below. + +If you are interested in _Jetzig_ you will probably find these tools interesting too: + +* [Zap](https://github.com/zigzap/zap) +* [http.zig](https://github.com/karlseguin/http.zig) + +## Checklist + +* :white_check_mark: File system-based routing with [slug] matching. +* :white_check_mark: _HTML_ and _JSON_ response (inferred from extension and/or `Accept` header). +* :white_check_mark: _JSON_-compatible response data builder. +* :white_check_mark: _HTML_ templating (see [Zmpl](https://github.com/bobf/zmpl). +* :white_check_mark: Per-request arena allocator. +* :x: Sessions. +* :x: Cookies. +* :x: Headers. +* :x: Development-mode responses for debugging. +* :x: Middleware extensions (for e.g. authentication). +* :x: Email delivery. +* :x: Custom/dynamic routes. +* :x: General-purpose cache. +* :x: Background jobs. +* :x: Database integration. +* :x: Email receipt (via SendGrid/AWS SES/etc.) + +## LICENSE + +[MIT](LICENSE) diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..92bdd43 --- /dev/null +++ b/build.zig @@ -0,0 +1,98 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const lib = b.addStaticLibrary(.{ + .name = "jetzig", + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + b.installArtifact(lib); + + const exe = b.addExecutable(.{ + .name = "jetzig", + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + b.installArtifact(exe); + + const jetzig_module = b.createModule(.{ .source_file = .{ .path = "src/jetzig.zig" } }); + exe.addModule("jetzig", jetzig_module); + lib.addModule("jetzig", jetzig_module); + try b.modules.put("jetzig", jetzig_module); + + try b.env_map.put("ZMPL_TEMPLATES_PATH", "src/app/views"); + const zmpl_module = b.dependency("zmpl", .{ .target = target, .optimize = optimize }); + lib.addModule("zmpl", zmpl_module.module("zmpl")); + exe.addModule("zmpl", zmpl_module.module("zmpl")); + + var dir = std.fs.cwd(); + var file = try dir.createFile("src/app/views/routes.zig", .{ .truncate = true }); + try file.writeAll("pub const routes = .{\n"); + const views = try findViews(b.allocator); + for (views.items) |view| { + try file.writeAll(try std.fmt.allocPrint(b.allocator, " @import(\"{s}\"),\n", .{view.path})); + std.debug.print("[jetzig] Imported view: {s}\n", .{view.path}); + } + + try file.writeAll("};\n"); + file.close(); + + const run_cmd = b.addRunArtifact(exe); + + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const main_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const run_main_tests = b.addRunArtifact(main_tests); + + const test_step = b.step("test", "Run library tests"); + test_step.dependOn(&run_main_tests.step); +} + +const ViewItem = struct { + path: []const u8, + name: []const u8, +}; + +fn findViews(allocator: std.mem.Allocator) !std.ArrayList(*ViewItem) { + var array = std.ArrayList(*ViewItem).init(allocator); + const dir = try std.fs.cwd().openIterableDir("src/app/views", .{}); + var walker = try dir.walk(allocator); + defer walker.deinit(); + while (try walker.next()) |entry| { + if (entry.kind != .file) continue; + const extension = std.fs.path.extension(entry.path); + const basename = std.fs.path.basename(entry.path); + if (std.mem.eql(u8, basename, "routes.zig")) continue; + if (std.mem.eql(u8, basename, "manifest.zig")) continue; + if (std.mem.startsWith(u8, basename, ".")) continue; + if (!std.mem.eql(u8, extension, ".zig")) continue; + + var sanitized_array = std.ArrayList(u8).init(allocator); + for (entry.path) |char| { + if (std.mem.indexOfAny(u8, &[_]u8{char}, "abcdefghijklmnopqrstuvwxyz")) |_| try sanitized_array.append(char); + } + const ptr = try allocator.create(ViewItem); + ptr.* = .{ + .path = try allocator.dupe(u8, entry.path), + .name = try allocator.dupe(u8, sanitized_array.items), + }; + try array.append(ptr); + } + return array; +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..1206c8c --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,61 @@ +.{ + .name = "jetzig", + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // This field is optional. + // This is currently advisory only; Zig does not yet do anything + // with this value. + //.minimum_zig_version = "0.11.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // Internet connectivity. + .dependencies = .{ + // See `zig fetch --save ` for a command-line interface for adding dependencies. + .zmpl = .{ + // When updating this field to a new URL, be sure to delete the corresponding + // `hash`, otherwise you are communicating that you expect to find the old hash at + // the new URL. + .url = "https://github.com/bobf/zmpl/archive/refs/tags/0.0.1.tar.gz", + .hash = "1220eedaec5c45067b0158251fd49f3ecc9c7c4676f7e3e0567ca2b2c465fa6cd983", + + // This is computed from the file contents of the directory of files that is + // obtained after fetching `url` and applying the inclusion rules given by + // `paths`. + // + // This field is the source of truth; packages do not come from an `url`; they + // come from a `hash`. `url` is just one of many possible mirrors for how to + // obtain a package matching this `hash`. + // + // Uses the [multihash](https://multiformats.io/multihash/) format. + + // When this is provided, the package is found in a directory relative to the + // build root. In this case the package's hash is irrelevant and therefore not + // computed. This field and `url` are mutually exclusive. + }, + }, + + // Specifies the set of files and directories that are included in this package. + // Only files and directories listed here are included in the `hash` that + // is computed for this package. + // Paths are relative to the build root. Use the empty string (`""`) to refer to + // the build root itself. + // A directory listed here means that all files within, recursively, are included. + .paths = .{ + // This makes *all* files, recursively, included in this package. It is generally + // better to explicitly list the files and directories instead, to insure that + // fetching from tarballs, file system paths, and version control all result + // in the same contents hash. + "", + // For example... + "build.zig", + "build.zig.zon", + "src/jetzig", + "LICENSE", + "README.md", + }, +} diff --git a/main.zig b/main.zig deleted file mode 100644 index 68ca81c..0000000 --- a/main.zig +++ /dev/null @@ -1,136 +0,0 @@ -const std = @import("std"); - -const HttpServerOptions = struct { - use_cache: bool, -}; - -const HttpServer = struct { - server: std.http.Server, - allocator: std.mem.Allocator, - page_cache: std.StringHashMap([]const u8), - port: u16, - host: []const u8, - options: HttpServerOptions, - - const Self = @This(); - - pub fn init( - allocator: std.mem.Allocator, - cache: std.StringHashMap([]const u8), - host: []const u8, - port: u16, - options: HttpServerOptions, - ) HttpServer { - const server = std.http.Server.init(allocator, .{ .reuse_address = true }); - - return .{ - .server = server, - .allocator = allocator, - .page_cache = cache, - .host = host, - .port = port, - .options = options, - }; - } - - pub fn deinit(self: *Self) void { - self.server.deinit(); - } - - pub fn listen(self: *Self) !void { - const address = std.net.Address.parseIp(self.host, self.port) catch unreachable; - - try self.server.listen(address); - std.debug.print( - "Listening on http://{s}:{} [cache:{s}]\n", - .{ self.host, self.port, if (self.options.use_cache) "enabled" else "disabled" } - ); - try self.processRequests(); - } - - fn processRequests(self: *Self) !void { - while (true) { - self.processNextRequest() catch |err| { - switch(err) { - error.EndOfStream => continue, - error.ConnectionResetByPeer => continue, - else => return err, - } - }; - } - } - - fn processNextRequest(self: *Self) !void { - var response = try self.server.accept(.{ .allocator = self.allocator }); - defer response.deinit(); - - try response.wait(); - - const content = try self.pageContent(response.request.method, response.request.target); - - response.transfer_encoding = .{ .content_length = content.len }; - try response.send(); - try response.writeAll(content); - try response.finish(); - } - - fn pageContent(self: *Self, method: std.http.Method, target: []const u8) ![]const u8 { - var buffer: [1<<16]u8 = undefined; - const method_str = switch(method) { - .POST => "post", - else => "get" - }; - const path = try std.mem.concat(self.allocator, u8, &[_][]const u8{ method_str, target }); - - // std.debug.print("{s} {s}\n", .{ method_str, target }); - - if (self.options.use_cache and self.page_cache.contains(path)) { - defer self.allocator.free(path); - - if (self.page_cache.get(path)) |cached_content| return cached_content; - } - - const content = std.fs.cwd().readFile(path, &buffer) catch |err| { - return switch(err) { - error.FileNotFound => { - std.debug.print("File not found: {s}", .{path}); - return ""; - }, - else => err - }; - }; - - try self.page_cache.put(path, content); - - return content; - } -}; - -pub fn main() !void { - var general_purpose_allocator = std.heap.GeneralPurposeAllocator(.{}){}; - defer std.debug.assert(general_purpose_allocator.deinit() == .ok); - const allocator = general_purpose_allocator.allocator(); - var page_cache = std.StringHashMap([]const u8).init(allocator); - defer page_cache.deinit(); - - const args = try std.process.argsAlloc(allocator); - defer std.process.argsFree(allocator, args); - - const host: []const u8 = if (args.len > 1) args[1] else "127.0.0.1"; - const port: u16 = if (args.len > 2) try std.fmt.parseInt(u16, args[2], 10) else 3040; - - var server: HttpServer = HttpServer.init( - allocator, - page_cache, - host, - port, - HttpServerOptions{ .use_cache = false }, - ); - - defer server.deinit(); - - server.listen() catch |err| { - std.debug.print("{}\nExiting.\n", .{err}); - return err; - }; -} diff --git a/src/app/views/foo/bar/baz.zig b/src/app/views/foo/bar/baz.zig new file mode 100644 index 0000000..ad1f37c --- /dev/null +++ b/src/app/views/foo/bar/baz.zig @@ -0,0 +1,48 @@ +const std = @import("std"); + +const root = @import("root"); + +pub fn index(request: *root.jetzig.http.Request) anyerror!root.jetzig.views.View { + var data = request.data(); + var object = try data.object(); + + try object.add("message", data.string("hello there")); + try object.add("foo", data.string("foo lookup")); + try object.add("bar", data.string("bar lookup")); + + return request.render(.ok); +} + +pub fn get(id: []const u8, request: *root.jetzig.http.Request) anyerror!root.jetzig.views.View { + var data = request.data(); + var object = try data.object(); + + try object.add("message", data.string("hello there")); + try object.add("other_message", data.string("hello again")); + try object.add("an_integer", data.integer(10)); + try object.add("a_float", data.float(1.345)); + try object.add("a_boolean", data.boolean(true)); + try object.add("Null", data.Null); + try object.add("a_random_integer", data.integer(std.crypto.random.int(u8))); + try object.add("resource_id", data.string(id)); + + var nested_object = try data.object(); + try nested_object.add("nested key", data.string("nested value")); + try object.add("other_message", nested_object.*); + + return request.render(.ok); +} + +pub fn patch(id: []const u8, request: *root.jetzig.http.Request) anyerror!root.jetzig.views.View { + var data = request.data(); + var object = try data.object(); + + const integer = std.crypto.random.int(u8); + + try object.add("message", data.string("hello there")); + try object.add("other_message", data.string("hello again")); + try object.add("other_message", data.integer(integer)); + try object.add("id", data.string(id)); + + return request.render(.ok); +} diff --git a/src/app/views/foo/bar/baz/get.zmpl b/src/app/views/foo/bar/baz/get.zmpl new file mode 100644 index 0000000..0c41920 --- /dev/null +++ b/src/app/views/foo/bar/baz/get.zmpl @@ -0,0 +1,14 @@ +if (std.mem.eql(u8, try zmpl.getValueString("message"), "hello there")) { + const foo = "foo const"; + const bar = "bar const"; + + inline for (1..4) |index| { +
Hello {:foo}!
+
Hello {:bar}!
+
Hello {:index}!
+
Hello {.foo}!
+
Hello {.bar}!
+ } +} else { +
Unexpected message
+} diff --git a/src/app/views/foo/bar/baz/get[id].zmpl b/src/app/views/foo/bar/baz/get[id].zmpl new file mode 100644 index 0000000..3346d04 --- /dev/null +++ b/src/app/views/foo/bar/baz/get[id].zmpl @@ -0,0 +1 @@ +{.resource_id} diff --git a/src/app/views/manifest.zig b/src/app/views/manifest.zig new file mode 100644 index 0000000..ba1eecf --- /dev/null +++ b/src/app/views/manifest.zig @@ -0,0 +1,7 @@ +// Zmpl template manifest. +// This file is automatically generated at build time. Manual edits will be discarded. +// This file should _not_ be stored in version control. +pub const templates = struct { + pub const foo_bar_baz_get = @import("foo/bar/baz/.get.zmpl.compiled.zig"); + pub const foo_bar_baz_get_id = @import("foo/bar/baz/.get[id].zmpl.compiled.zig"); +}; diff --git a/src/app/views/routes.zig b/src/app/views/routes.zig new file mode 100644 index 0000000..1fc3613 --- /dev/null +++ b/src/app/views/routes.zig @@ -0,0 +1,3 @@ +pub const routes = .{ + @import("foo/bar/baz.zig"), +}; diff --git a/src/jetzig.zig b/src/jetzig.zig new file mode 100644 index 0000000..046df51 --- /dev/null +++ b/src/jetzig.zig @@ -0,0 +1,111 @@ +const std = @import("std"); + +pub const zmpl = @import("zmpl"); + +pub const http = @import("jetzig/http.zig"); +pub const loggers = @import("jetzig/loggers.zig"); +pub const caches = @import("jetzig/caches.zig"); +pub const views = @import("jetzig/views.zig"); +pub const colors = @import("jetzig/colors.zig"); +pub const App = @import("jetzig/App.zig"); +pub const DefaultAllocator = std.heap.GeneralPurposeAllocator(.{}); + +pub fn init(allocator: std.mem.Allocator) !App { + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + const host: []const u8 = if (args.len > 1) + try allocator.dupe(u8, args[1]) + else + try allocator.dupe(u8, "127.0.0.1"); + + // TODO: Fix this up with proper arg parsing + const port: u16 = if (args.len > 2) try std.fmt.parseInt(u16, args[2], 10) else 8080; + const use_cache: bool = args.len > 3 and std.mem.eql(u8, args[3], "--cache"); + var debug: bool = args.len > 3 and std.mem.eql(u8, args[3], "--debug"); + debug = debug or (args.len > 4 and std.mem.eql(u8, args[4], "--debug")); + const server_cache = switch (use_cache) { + true => caches.Cache{ .memory_cache = caches.MemoryCache.init(allocator) }, + false => caches.Cache{ .null_cache = caches.NullCache.init(allocator) }, + }; + const root_path = std.fs.cwd().realpathAlloc(allocator, "src/app") catch |err| { + switch (err) { + error.FileNotFound => { + std.debug.print("Unable to find base directory: ./app\nExiting.\n", .{}); + std.os.exit(1); + }, + else => return err, + } + }; + + const logger = loggers.Logger{ .development_logger = loggers.DevelopmentLogger.init(allocator, debug) }; + const server_options = http.Server.ServerOptions{ + .cache = server_cache, + .logger = logger, + .root_path = root_path, + }; + + return .{ + .server_options = server_options, + .allocator = allocator, + .host = host, + .port = port, + .root_path = root_path, + }; +} + +// Receives an array of imported modules and detects functions defined on them. +// Each detected function is stored as a Route which can be accessed at runtime to route requests +// to the appropriate View. +pub fn route(comptime modules: anytype) []views.Route { + var size: u16 = 0; + + for (modules) |module| { + const decls = @typeInfo(module).Struct.decls; + + for (decls) |_| size += 1; + } + + var detected: [size]views.Route = undefined; + + for (modules, 1..) |module, module_index| { + const decls = @typeInfo(module).Struct.decls; + + for (decls, 1..) |decl, decl_index| { + const view = @unionInit(views.Route.ViewType, decl.name, @field(module, decl.name)); + + detected[module_index * decl_index - 1] = .{ + .name = @typeName(module), + .action = @field(views.Route.Action, decl.name), + .view = view, + }; + } + } + + return &detected; +} + +// Receives a type (an imported module). All pub const declarations are considered as compiled +// Zmpl templates, each implementing a `render` function. +pub fn loadTemplates(comptime module: type) []TemplateFn { + var size: u16 = 0; + const decls = @typeInfo(module).Struct.decls; + + for (decls) |_| size += 1; + + var detected: [size]TemplateFn = undefined; + + for (decls, 0..) |decl, decl_index| { + detected[decl_index] = .{ + .render = @field(module, decl.name).render, + .name = decl.name, + }; + } + + return &detected; +} + +pub const TemplateFn = struct { + name: []const u8, + render: *const fn (*zmpl.Data) anyerror![]const u8, +}; diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig new file mode 100644 index 0000000..5ecffae --- /dev/null +++ b/src/jetzig/App.zig @@ -0,0 +1,51 @@ +const std = @import("std"); + +const root = @import("root"); + +const Self = @This(); + +server_options: root.jetzig.http.Server.ServerOptions, +allocator: std.mem.Allocator, +host: []const u8, +port: u16, +root_path: []const u8, + +pub fn render(self: *const Self, data: anytype) root.views.View { + _ = self; + return .{ .data = data }; +} + +pub fn deinit(self: Self) void { + _ = self; +} + +pub fn start(self: Self, views: []root.jetzig.views.Route, templates: []root.jetzig.TemplateFn) !void { + var server = root.jetzig.http.Server.init( + self.allocator, + self.host, + self.port, + self.server_options, + views, + templates, + ); + + defer server.deinit(); + defer self.allocator.free(self.root_path); + defer self.allocator.free(self.host); + + server.listen() catch |err| { + switch (err) { + error.AddressInUse => { + server.logger.debug( + "Socket unavailable: {s}:{} - unable to start server.\n", + .{ self.host, self.port }, + ); + return; + }, + else => { + server.logger.debug("Encountered error: {}\nExiting.\n", .{err}); + return err; + }, + } + }; +} diff --git a/src/jetzig/caches.zig b/src/jetzig/caches.zig new file mode 100644 index 0000000..875a053 --- /dev/null +++ b/src/jetzig/caches.zig @@ -0,0 +1,30 @@ +const std = @import("std"); + +const http = @import("http.zig"); + +pub const Result = @import("caches/Result.zig"); +pub const MemoryCache = @import("caches/MemoryCache.zig"); +pub const NullCache = @import("caches/NullCache.zig"); + +pub const Cache = union(enum) { + memory_cache: MemoryCache, + null_cache: NullCache, + + pub fn deinit(self: *Cache) void { + switch (self.*) { + inline else => |*case| try case.deinit(), + } + } + + pub fn get(self: *Cache, key: []const u8) ?Result { + return switch (self.*) { + inline else => |*case| case.get(key), + }; + } + + pub fn put(self: *Cache, key: []const u8, value: http.Response) !Result { + return switch (self.*) { + inline else => |*case| case.put(key, value), + }; + } +}; diff --git a/src/jetzig/caches/Cache.zig b/src/jetzig/caches/Cache.zig new file mode 100644 index 0000000..0769537 --- /dev/null +++ b/src/jetzig/caches/Cache.zig @@ -0,0 +1,5 @@ +const std = @import("std"); + +pub const Result = @import("Result.zig"); +pub const MemoryCache = @import("MemoryCache.zig"); +pub const NullCache = @import("NullCache.zig"); diff --git a/src/jetzig/caches/MemoryCache.zig b/src/jetzig/caches/MemoryCache.zig new file mode 100644 index 0000000..1363b0e --- /dev/null +++ b/src/jetzig/caches/MemoryCache.zig @@ -0,0 +1,39 @@ +const std = @import("std"); + +const http = @import("../http.zig"); +const Result = @import("Result.zig"); + +allocator: std.mem.Allocator, +cache: std.StringHashMap(http.Response), + +const Self = @This(); + +pub fn init(allocator: std.mem.Allocator) Self { + const cache = std.StringHashMap(http.Response).init(allocator); + + return .{ .allocator = allocator, .cache = cache }; +} + +pub fn deinit(self: *Self) void { + var iterator = self.cache.keyIterator(); + while (iterator.next()) |key| { + self.allocator.free(key.*); + } + self.cache.deinit(); +} + +pub fn get(self: *Self, key: []const u8) ?Result { + if (self.cache.get(key)) |value| { + return Result.init(self.allocator, value, true); + } else { + return null; + } +} + +pub fn put(self: *Self, key: []const u8, value: http.Response) !Result { + const key_dupe = try self.allocator.dupe(u8, key); + const value_dupe = try value.dupe(); + try self.cache.put(key_dupe, value_dupe); + + return Result.init(self.allocator, value_dupe, true); +} diff --git a/src/jetzig/caches/NullCache.zig b/src/jetzig/caches/NullCache.zig new file mode 100644 index 0000000..1c67aa0 --- /dev/null +++ b/src/jetzig/caches/NullCache.zig @@ -0,0 +1,27 @@ +const std = @import("std"); + +const http = @import("../http.zig"); +const Result = @import("Result.zig"); + +const Self = @This(); + +allocator: std.mem.Allocator, + +pub fn init(allocator: std.mem.Allocator) Self { + return Self{ .allocator = allocator }; +} + +pub fn deinit(self: *const Self) void { + _ = self; +} + +pub fn get(self: *const Self, key: []const u8) ?Result { + _ = key; + _ = self; + return null; +} + +pub fn put(self: *const Self, key: []const u8, value: http.Response) !Result { + _ = key; + return Result{ .value = value, .cached = false, .allocator = self.allocator }; +} diff --git a/src/jetzig/caches/Result.zig b/src/jetzig/caches/Result.zig new file mode 100644 index 0000000..a9a1786 --- /dev/null +++ b/src/jetzig/caches/Result.zig @@ -0,0 +1,17 @@ +const std = @import("std"); + +const Self = @This(); + +const root = @import("root"); + +value: root.jetzig.http.Response, +cached: bool, +allocator: std.mem.Allocator, + +pub fn init(allocator: std.mem.Allocator, value: root.jetzig.http.Response, cached: bool) Self { + return .{ .allocator = allocator, .cached = cached, .value = value }; +} + +pub fn deinit(self: *const Self) void { + if (!self.cached) self.value.deinit(); +} diff --git a/src/jetzig/colors.zig b/src/jetzig/colors.zig new file mode 100644 index 0000000..338f818 --- /dev/null +++ b/src/jetzig/colors.zig @@ -0,0 +1,105 @@ +const std = @import("std"); + +const types = @import("types.zig"); + +const codes = .{ + .escape = "\x1B[", + .reset = "0;0", + .black = "0;30", + .red = "0;31", + .green = "0;32", + .yellow = "0;33", + .blue = "0;34", + .purple = "0;35", + .cyan = "0;36", + .white = "0;37", +}; + +fn wrap(comptime attribute: []const u8, comptime message: []const u8) []const u8 { + return codes.escape ++ attribute ++ "m" ++ message ++ codes.escape ++ codes.reset ++ "m"; +} + +fn runtimeWrap(allocator: std.mem.Allocator, attribute: []const u8, message: []const u8) ![]const u8 { + return try std.mem.join( + allocator, + "", + &[_][]const u8{ codes.escape, attribute, "m", message, codes.escape, codes.reset, "m" }, + ); +} + +pub fn black(comptime message: []const u8) []const u8 { + return wrap(codes.black, message); +} + +pub fn runtimeBlack(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.black, message); +} + +pub fn red(comptime message: []const u8) []const u8 { + return wrap(codes.red, message); +} + +pub fn runtimeRed(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.red, message); +} + +pub fn green(comptime message: []const u8) []const u8 { + return wrap(codes.green, message); +} + +pub fn runtimeGreen(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.green, message); +} + +pub fn yellow(comptime message: []const u8) []const u8 { + return wrap(codes.yellow, message); +} + +pub fn runtimeYellow(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.yellow, message); +} + +pub fn blue(comptime message: []const u8) []const u8 { + return wrap(codes.blue, message); +} + +pub fn runtimeBlue(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.blue, message); +} + +pub fn purple(comptime message: []const u8) []const u8 { + return wrap(codes.purple, message); +} + +pub fn runtimePurple(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.purple, message); +} + +pub fn cyan(comptime message: []const u8) []const u8 { + return wrap(codes.cyan, message); +} + +pub fn runtimeCyan(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.cyan, message); +} + +pub fn white(comptime message: []const u8) []const u8 { + return wrap(codes.white, message); +} + +pub fn runtimeWhite(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.white, message); +} + +pub fn duration(allocator: std.mem.Allocator, delta: i64) ![]const u8 { + var buf: [1024]u8 = undefined; + const formatted_duration = try std.fmt.bufPrint(&buf, "{}", .{std.fmt.fmtDurationSigned(delta)}); + + if (delta < 100000) { + return try runtimeGreen(allocator, formatted_duration); + } else if (delta < 500000) { + return try runtimeYellow(allocator, formatted_duration); + } else { + return try runtimeRed(allocator, formatted_duration); + } +} diff --git a/src/jetzig/http.zig b/src/jetzig/http.zig new file mode 100644 index 0000000..d5019be --- /dev/null +++ b/src/jetzig/http.zig @@ -0,0 +1,8 @@ +const std = @import("std"); + +const colors = @import("colors.zig"); + +pub const Server = @import("http/Server.zig"); +pub const Request = @import("http/Request.zig"); +pub const Response = @import("http/Response.zig"); +pub const status_codes = @import("http/status_codes.zig"); diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig new file mode 100644 index 0000000..ae80634 --- /dev/null +++ b/src/jetzig/http/Request.zig @@ -0,0 +1,213 @@ +const std = @import("std"); + +const root = @import("root"); + +const Self = @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: []const u8, +method: Method, +headers: std.http.Headers, +segments: std.ArrayList([]const u8), +server: *root.jetzig.http.Server, +status_code: root.jetzig.http.status_codes.StatusCode = undefined, +response_data: root.jetzig.views.data.Data = undefined, + +pub fn init( + allocator: std.mem.Allocator, + server: *root.jetzig.http.Server, + request: std.http.Server.Request, +) !Self { + const method = switch (request.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, + }; + + var it = std.mem.splitScalar(u8, request.target, '/'); + var segments = std.ArrayList([]const u8).init(allocator); + while (it.next()) |segment| try segments.append(segment); + + return .{ + .allocator = allocator, + .path = request.target, + .method = method, + .headers = request.headers, + .server = server, + .segments = segments, + }; +} + +pub fn deinit(self: *Self) void { + defer self.segments.deinit(); +} + +pub fn render(self: *Self, status_code: root.jetzig.http.status_codes.StatusCode) root.jetzig.views.View { + return .{ .data = &self.response_data, .status_code = status_code }; +} + +pub fn requestFormat(self: *Self) root.jetzig.http.Request.Format { + return self.extensionFormat() orelse self.acceptHeaderFormat() orelse .UNKNOWN; +} + +pub fn getHeader(self: *Self, key: []const u8) ?[]const u8 { + return self.headers.getFirstValue(key); +} + +fn extensionFormat(self: *Self) ?root.jetzig.http.Request.Format { + const extension = std.fs.path.extension(self.path); + + 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: *Self) ?root.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 hash(self: *Self) ![]const u8 { + return try std.fmt.allocPrint( + self.allocator, + "{s}-{s}-{s}", + .{ @tagName(self.method), self.path, @tagName(self.requestFormat()) }, + ); +} + +pub fn fullPath(self: *Self) ![]const u8 { + const base_path = try std.fs.path.join(self.allocator, &[_][]const u8{ + self.server.options.root_path, + "views", + }); + defer self.allocator.free(base_path); + + const resource_path = try self.resourcePath(); + defer self.allocator.free(resource_path); + const full_path = try std.fs.path.join(self.allocator, &[_][]const u8{ + base_path, + resource_path, + self.resourceName(), + self.templateName(), + }); + defer self.allocator.free(full_path); + return self.allocator.dupe(u8, full_path); +} + +pub fn resourceModifier(self: *Self) ?Modifier { + const basename = std.fs.path.basename(self.segments.items[self.segments.items.len - 1]); + const extension = std.fs.path.extension(basename); + const resource = basename[0 .. basename.len - extension.len]; + if (std.mem.eql(u8, resource, "edit")) return .edit; + if (std.mem.eql(u8, resource, "new")) return .new; + + return null; +} + +pub fn resourceName(self: *Self) []const u8 { + const basename = std.fs.path.basename(self.segments.items[self.segments.items.len - 1]); + const extension = std.fs.path.extension(basename); + return basename[0 .. basename.len - extension.len]; +} + +pub fn resourcePath(self: *Self) ![]const u8 { + const path = try std.fs.path.join( + self.allocator, + self.segments.items[0 .. self.segments.items.len - 1], + ); + defer self.allocator.free(path); + return try std.mem.concat(self.allocator, u8, &[_][]const u8{ "/", path }); +} + +pub fn resourceId(self: *Self) []const u8 { + return self.resourceName(); +} + +pub fn match(self: *Self, route: root.jetzig.views.Route) !bool { + switch (self.method) { + .GET => { + return switch (route.action) { + .index => std.mem.eql(u8, try self.nameWithResourceId(), route.name), + .get => std.mem.eql(u8, try self.nameWithoutResourceId(), route.name), + else => false, + }; + }, + .POST => return route.action == .post, + .PUT => return route.action == .put, + .PATCH => return route.action == .patch, + .DELETE => return route.action == .delete, + else => return false, + } + + return false; +} + +pub fn data(self: *Self) *root.jetzig.views.data.Data { + self.response_data = root.jetzig.views.data.Data.init(self.allocator); + return &self.response_data; +} + +fn templateName(self: *Self) []const u8 { + switch (self.method) { + .GET => return "index.html.zmpl", + .SHOW => return "[id].html.zmpl", + else => unreachable, // TODO: Missing HTTP verbs. + } +} + +fn isEditAction(self: *Self) bool { + if (self.resourceModifier()) |modifier| { + return modifier == .edit; + } else return false; +} + +fn isNewAction(self: *Self) bool { + if (self.resourceModifier()) |modifier| { + return modifier == .new; + } else return false; +} + +fn nameWithResourceId(self: *Self) ![]const u8 { + return try self.name(true); +} + +fn nameWithoutResourceId(self: *Self) ![]const u8 { + return try self.name(false); +} + +fn name(self: *Self, with_resource_id: bool) ![]const u8 { + const dirname = try std.mem.join( + self.allocator, + ".", + self.segments.items[0 .. self.segments.items.len - 1], + ); + defer self.allocator.free(dirname); + + return std.mem.concat(self.allocator, u8, &[_][]const u8{ + "app.views", + dirname, + if (with_resource_id) "." else "", + if (with_resource_id) self.resourceName() else "", + }); +} diff --git a/src/jetzig/http/Response.zig b/src/jetzig/http/Response.zig new file mode 100644 index 0000000..cff76af --- /dev/null +++ b/src/jetzig/http/Response.zig @@ -0,0 +1,22 @@ +const std = @import("std"); + +const http = @import("../http.zig"); + +const Self = @This(); + +allocator: std.mem.Allocator, +content: []const u8, +status_code: http.status_codes.StatusCode, + +pub fn deinit(self: *const Self) void { + _ = self; + // self.allocator.free(self.content); +} + +pub fn dupe(self: *const Self) !Self { + return .{ + .allocator = self.allocator, + .status_code = self.status_code, + .content = try self.allocator.dupe(u8, self.content), + }; +} diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig new file mode 100644 index 0000000..56cafdd --- /dev/null +++ b/src/jetzig/http/Server.zig @@ -0,0 +1,208 @@ +const std = @import("std"); + +const root = @import("root"); + +pub const ServerOptions = struct { + cache: root.jetzig.caches.Cache, + logger: root.jetzig.loggers.Logger, + root_path: []const u8, +}; + +server: std.http.Server, +allocator: std.mem.Allocator, +port: u16, +host: []const u8, +cache: root.jetzig.caches.Cache, +logger: root.jetzig.loggers.Logger, +options: ServerOptions, +start_time: i128 = undefined, +routes: []root.jetzig.views.Route, +templates: []root.jetzig.TemplateFn, + +const Self = @This(); + +pub fn init( + allocator: std.mem.Allocator, + host: []const u8, + port: u16, + options: ServerOptions, + routes: []root.jetzig.views.Route, + templates: []root.jetzig.TemplateFn, +) Self { + const server = std.http.Server.init(allocator, .{ .reuse_address = true }); + + return .{ + .server = server, + .allocator = allocator, + .host = host, + .port = port, + .cache = options.cache, + .logger = options.logger, + .options = options, + .routes = routes, + .templates = templates, + }; +} + +pub fn deinit(self: *Self) void { + self.server.deinit(); +} + +pub fn listen(self: *Self) !void { + const address = std.net.Address.parseIp(self.host, self.port) catch unreachable; + + try self.server.listen(address); + const cache_status = if (self.options.cache == .null_cache) "disabled" else "enabled"; + self.logger.debug("Listening on http://{s}:{} [cache:{s}]", .{ self.host, self.port, cache_status }); + try self.processRequests(); +} + +fn processRequests(self: *Self) !void { + while (true) { + self.processNextRequest() catch |err| { + switch (err) { + error.EndOfStream => continue, + error.ConnectionResetByPeer => continue, + else => return err, + } + }; + } +} + +fn processNextRequest(self: *Self) !void { + var response = try self.server.accept(.{ .allocator = self.allocator }); + defer response.deinit(); + try response.wait(); + self.start_time = std.time.nanoTimestamp(); + + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + + var request = try root.jetzig.http.Request.init(arena.allocator(), self, response.request); + defer request.deinit(); + + const result = try self.pageContent(&request); + defer result.deinit(); + + response.transfer_encoding = .{ .content_length = result.value.content.len }; + response.status = switch (result.value.status_code) { + .ok => .ok, + .not_found => .not_found, + }; + + try response.do(); + try response.writeAll(result.value.content); + try response.finish(); + + const log_message = try self.requestLogMessage(&request, result); + defer self.allocator.free(log_message); + self.logger.debug("{s}", .{log_message}); +} + +fn pageContent(self: *Self, request: *root.jetzig.http.Request) !root.jetzig.caches.Result { + const cache_key = try request.hash(); + + if (self.cache.get(cache_key)) |item| { + return item; + } else { + const response = try self.renderResponse(request); + return try self.cache.put(cache_key, response); + } +} + +fn renderResponse(self: *Self, request: *root.jetzig.http.Request) !root.jetzig.http.Response { + const view = try self.matchView(request); + + switch (request.requestFormat()) { + .HTML => return self.renderHTML(request, view), + .JSON => return self.renderJSON(request, view), + .UNKNOWN => return self.renderHTML(request, view), + } +} + +fn renderHTML( + self: *Self, + request: *root.jetzig.http.Request, + route: ?root.jetzig.views.Route, +) !root.jetzig.http.Response { + if (route) |matched_route| { + const expected_name = try matched_route.templateName(self.allocator); + defer self.allocator.free(expected_name); + + for (self.templates) |template| { + // FIXME: Tidy this up and use a hashmap for templates instead of an array. + if (std.mem.eql(u8, expected_name, template.name)) { + const view = try matched_route.render(matched_route, request); + const content = try template.render(view.data); + return .{ .allocator = self.allocator, .content = content, .status_code = .ok }; + } + } + + return .{ + .allocator = self.allocator, + .content = "", + .status_code = .not_found, + }; + } else { + return .{ + .allocator = self.allocator, + .content = "", + .status_code = .not_found, + }; + } +} + +fn renderJSON( + self: *Self, + request: *root.jetzig.http.Request, + route: ?root.jetzig.views.Route, +) !root.jetzig.http.Response { + if (route) |matched_route| { + const view = try matched_route.render(matched_route, request); + var data = view.data; + return .{ + .allocator = self.allocator, + .content = try data.toJson(), + .status_code = .ok, + }; + } else return .{ + .allocator = self.allocator, + .content = "", + .status_code = .not_found, + }; +} + +fn requestLogMessage(self: *Self, request: *root.jetzig.http.Request, result: root.jetzig.caches.Result) ![]const u8 { + const status: root.jetzig.http.status_codes.TaggedStatusCode = switch (result.value.status_code) { + .ok => .{ .ok = .{} }, + .not_found => .{ .not_found = .{} }, + }; + + const formatted_duration = try root.jetzig.colors.duration(self.allocator, self.duration()); + defer self.allocator.free(formatted_duration); + + return try std.fmt.allocPrint(self.allocator, "[{s} {s}] {s} {s}", .{ + status.format(), + formatted_duration, + @tagName(request.method), + request.path, + }); +} + +fn duration(self: *Self) i64 { + return @intCast(std.time.nanoTimestamp() - self.start_time); +} + +fn matchView(self: *Self, request: *root.jetzig.http.Request) !?root.jetzig.views.Route { + for (self.routes) |route| { + if (route.action == .index and try request.match(route)) return route; + } + + for (self.routes) |route| { + if (route.action == .get and try request.match(route)) return route; + } + + // TODO: edit, new, update, delete + + return null; +} diff --git a/src/jetzig/http/StatusCode.zig b/src/jetzig/http/StatusCode.zig new file mode 100644 index 0000000..61f1e18 --- /dev/null +++ b/src/jetzig/http/StatusCode.zig @@ -0,0 +1,48 @@ +const std = @import("std"); + +const root = @import("root"); + +pub const StatusCode = enum { + ok, + not_found, +}; + +pub fn StatusCodeType(comptime code: []const u8, comptime message: []const u8) type { + return struct { + code: []const u8 = code, + message: []const u8 = message, + + const Self = @This(); + + pub fn format(self: Self) []const u8 { + _ = self; + + const full_message = code ++ " " ++ message; + + if (std.mem.startsWith(u8, code, "2")) { + return root.colors.green(full_message); + } else if (std.mem.startsWith(u8, code, "3")) { + return root.colors.blue(full_message); + } else if (std.mem.startsWith(u8, code, "4")) { + return root.colors.yellow(full_message); + } else if (std.mem.startsWith(u8, code, "5")) { + return root.colors.red(full_message); + } else { + return full_message; + } + } + }; +} + +pub const StatusCodeUnion = union(StatusCode) { + ok: StatusCode("200", "OK"), + not_found: StatusCode("404", "Not Found"), + + const Self = @This(); + + pub fn format(self: Self) []const u8 { + return switch (self) { + inline else => |case| case.format(), + }; + } +}; diff --git a/src/jetzig/http/status_codes.zig b/src/jetzig/http/status_codes.zig new file mode 100644 index 0000000..af6c05e --- /dev/null +++ b/src/jetzig/http/status_codes.zig @@ -0,0 +1,48 @@ +const std = @import("std"); + +const root = @import("root"); + +pub const StatusCode = enum { + ok, + not_found, +}; + +pub fn StatusCodeType(comptime code: []const u8, comptime message: []const u8) type { + return struct { + code: []const u8 = code, + message: []const u8 = message, + + const Self = @This(); + + pub fn format(self: Self) []const u8 { + _ = self; + + const full_message = code ++ " " ++ message; + + if (std.mem.startsWith(u8, code, "2")) { + return root.jetzig.colors.green(full_message); + } else if (std.mem.startsWith(u8, code, "3")) { + return root.jetzig.colors.blue(full_message); + } else if (std.mem.startsWith(u8, code, "4")) { + return root.jetzig.colors.yellow(full_message); + } else if (std.mem.startsWith(u8, code, "5")) { + return root.jetzig.colors.red(full_message); + } else { + return full_message; + } + } + }; +} + +pub const TaggedStatusCode = union(StatusCode) { + ok: StatusCodeType("200", "OK"), + not_found: StatusCodeType("404", "Not Found"), + + const Self = @This(); + + pub fn format(self: Self) []const u8 { + return switch (self) { + inline else => |capture| capture.format(), + }; + } +}; diff --git a/src/jetzig/loggers.zig b/src/jetzig/loggers.zig new file mode 100644 index 0000000..53a4f6d --- /dev/null +++ b/src/jetzig/loggers.zig @@ -0,0 +1,21 @@ +const std = @import("std"); + +const Self = @This(); + +pub const DevelopmentLogger = @import("loggers/DevelopmentLogger.zig"); + +const LogLevel = enum { + debug, +}; + +pub const Logger = union(enum) { + development_logger: DevelopmentLogger, + + pub fn debug(self: *Logger, comptime message: []const u8, args: anytype) void { + switch (self.*) { + inline else => |*case| case.debug(message, args) catch |err| { + std.debug.print("{}\n", .{err}); + }, + } + } +}; diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig new file mode 100644 index 0000000..7172bbe --- /dev/null +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -0,0 +1,22 @@ +const std = @import("std"); + +const Self = @This(); +const Timestamp = @import("../types/Timestamp.zig"); + +allocator: std.mem.Allocator, +enabled: bool, + +pub fn init(allocator: std.mem.Allocator, enabled: bool) Self { + return .{ .allocator = allocator, .enabled = enabled }; +} + +pub fn debug(self: *Self, comptime message: []const u8, args: anytype) !void { + if (!self.enabled) return; + + const output = try std.fmt.allocPrint(self.allocator, message, args); + defer self.allocator.free(output); + const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); + const iso8601 = try timestamp.iso8601(); + defer self.allocator.free(iso8601); + std.debug.print("[{s}] {s}\n", .{ iso8601, output }); +} diff --git a/src/jetzig/types.zig b/src/jetzig/types.zig new file mode 100644 index 0000000..f0eda3a --- /dev/null +++ b/src/jetzig/types.zig @@ -0,0 +1 @@ +pub const Timestamp = @import("types/Timestamp.zig"); diff --git a/src/jetzig/types/Timestamp.zig b/src/jetzig/types/Timestamp.zig new file mode 100644 index 0000000..58c7720 --- /dev/null +++ b/src/jetzig/types/Timestamp.zig @@ -0,0 +1,85 @@ +const std = @import("std"); + +const Self = @This(); + +timestamp: i64, +allocator: std.mem.Allocator, + +const constants = struct { + pub const seconds_in_day: i64 = 60 * 60 * 24; + pub const seconds_in_year: i64 = 60 * 60 * 24 * 365.25; + pub const months: [12]i64 = .{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + pub const epoch_year: i64 = 1970; +}; + +pub fn init(timestamp: i64, allocator: std.mem.Allocator) Self { + return .{ .allocator = allocator, .timestamp = timestamp }; +} + +pub fn iso8601(self: *const Self) ![]const u8 { + const u32_year: u32 = @intCast(self.year()); + const u32_month: u32 = @intCast(self.month()); + const u32_day_of_month: u32 = @intCast(self.dayOfMonth()); + const u32_hour: u32 = @intCast(self.hour()); + const u32_minute: u32 = @intCast(self.minute()); + const u32_second: u32 = @intCast(self.second()); + return try std.fmt.allocPrint(self.allocator, "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}", .{ + u32_year, + u32_month, + u32_day_of_month, + u32_hour, + u32_minute, + u32_second, + }); +} + +pub fn year(self: *const Self) i64 { + return constants.epoch_year + @divTrunc(self.timestamp, constants.seconds_in_year); +} + +pub fn month(self: *const Self) usize { + const day_of_year = self.dayOfYear(); + var total_days: i64 = 0; + for (constants.months, 1..) |days, index| { + total_days += days; + if (day_of_year <= total_days) return index; + } + unreachable; +} + +pub fn dayOfYear(self: *const Self) i64 { + return @divTrunc(self.daysSinceEpoch(), constants.seconds_in_day); +} + +pub fn dayOfMonth(self: *const Self) i64 { + const day_of_year = self.dayOfYear(); + var total_days: i64 = 0; + for (constants.months) |days| { + total_days += days; + if (day_of_year <= total_days) return days + (day_of_year - total_days) + 1; + } + unreachable; +} + +pub fn daysSinceEpoch(self: *const Self) i64 { + return self.timestamp - ((self.year() - constants.epoch_year) * constants.seconds_in_year); +} + +pub fn dayOfWeek(self: *const Self) i64 { + const currentDay = std.math.mod(i64, self.daysSinceEpoch(), 7) catch unreachable; + return std.math.mod(i64, currentDay + 4, 7) catch unreachable; +} + +pub fn hour(self: *const Self) i64 { + const seconds = std.math.mod(i64, self.timestamp, constants.seconds_in_day) catch unreachable; + return @divTrunc(seconds, @as(i64, 60 * 60)); +} + +pub fn minute(self: *const Self) i64 { + const seconds = std.math.mod(i64, self.timestamp, @as(i64, 60 * 60)) catch unreachable; + return @divTrunc(seconds, @as(i64, 60)); +} + +pub fn second(self: *const Self) i64 { + return std.math.mod(i64, self.timestamp, @as(i64, 60)) catch unreachable; +} diff --git a/src/jetzig/views.zig b/src/jetzig/views.zig new file mode 100644 index 0000000..15a1c03 --- /dev/null +++ b/src/jetzig/views.zig @@ -0,0 +1,5 @@ +const root = @import("root"); + +pub const data = @import("views/data.zig"); +pub const Route = @import("views/Route.zig"); +pub const View = @import("views/View.zig"); diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig new file mode 100644 index 0000000..c54dbe9 --- /dev/null +++ b/src/jetzig/views/Route.zig @@ -0,0 +1,56 @@ +const std = @import("std"); + +const root = @import("root"); + +const Self = @This(); + +pub const Action = enum { index, get, post, put, patch, delete }; +pub const RenderFn = *const fn (Self, *root.jetzig.http.Request) anyerror!root.jetzig.views.View; + +const ViewWithoutId = *const fn (*root.jetzig.http.Request) anyerror!root.jetzig.views.View; +const ViewWithId = *const fn (id: []const u8, *root.jetzig.http.Request) anyerror!root.jetzig.views.View; + +pub const ViewType = union(Action) { + index: ViewWithoutId, + get: ViewWithId, + post: ViewWithoutId, + put: ViewWithId, + patch: ViewWithId, + delete: ViewWithId, +}; + +name: []const u8, +action: Action, +view: ViewType, +render: RenderFn = renderFn, + +pub fn templateName(self: Self, allocator: std.mem.Allocator) ![]const u8 { + const underscored_name = try std.mem.replaceOwned(u8, allocator, self.name, ".", "_"); + defer allocator.free(underscored_name); + + // FIXME: Store names in a normalised way so we don't need to do this stuff: + const unprefixed = try allocator.dupe(u8, underscored_name["app_views_".len..self.name.len]); + defer allocator.free(unprefixed); + + const suffixed = try std.mem.concat(allocator, u8, &[_][]const u8{ + unprefixed, + "_", + switch (self.action) { + .get => "get_id", + else => @tagName(self.action), + }, + }); + + return suffixed; +} + +fn renderFn(self: Self, request: *root.jetzig.http.Request) anyerror!root.jetzig.views.View { + switch (self.view) { + .index => |view| return try view(request), + .get => |view| return try view(request.resourceId(), request), + .post => |view| return try view(request), + .patch => |view| return try view(request.resourceId(), request), + .put => |view| return try view(request.resourceId(), request), + .delete => |view| return try view(request.resourceId(), request), + } +} diff --git a/src/jetzig/views/View.zig b/src/jetzig/views/View.zig new file mode 100644 index 0000000..d7e2bc0 --- /dev/null +++ b/src/jetzig/views/View.zig @@ -0,0 +1,6 @@ +const std = @import("std"); + +const root = @import("root"); + +data: *root.jetzig.views.data.Data, +status_code: root.jetzig.http.status_codes.StatusCode, diff --git a/src/jetzig/views/data.zig b/src/jetzig/views/data.zig new file mode 100644 index 0000000..7d8b35c --- /dev/null +++ b/src/jetzig/views/data.zig @@ -0,0 +1,14 @@ +const std = @import("std"); + +const zmpl = @import("zmpl"); + +pub const Writer = zmpl.Data.Writer; +pub const Data = zmpl.Data; +pub const Value = zmpl.Data.Value; +pub const NullType = zmpl.Data.NullType; +pub const Float = zmpl.Data.Float; +pub const Integer = zmpl.Data.Integer; +pub const Boolean = zmpl.Data.Boolean; +pub const String = zmpl.Data.String; +pub const Object = zmpl.Data.Object; +pub const Array = zmpl.Data.Array; diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..c8940c3 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,19 @@ +const std = @import("std"); + +pub const jetzig = @import("jetzig.zig"); +pub const templates = @import("app/views/manifest.zig").templates; +pub const routes = @import("app/views/routes.zig").routes; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer std.debug.assert(gpa.deinit() == .ok); + const allocator = gpa.allocator(); + + const app = try jetzig.init(allocator); + defer app.deinit(); + + try app.start( + comptime jetzig.route(routes), + comptime jetzig.loadTemplates(templates), + ); +}