From dcf00d84604befe37ab65caf52fd058ba18e804b Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 24 Oct 2023 23:45:08 -0700 Subject: [PATCH] rework context This commit is a significant refactor that fixes a number of things. 1. Replaces the optional helpers import (which was always weird) with a mandatory interface import on behalf of the application. This is actually a good thing as it enables all things below. 2. Removes the severely awkward union that was the lambda context. Now, no matter how your handler runs, a single object with everything you need is fully populated and (nearly always) works as you would expect. There is a slight exception to this with AWS Lambda that is related to the service itself. It is also possible that not everything is passed in correctly for Cloudflare, which, if true, will be addressed later. 3. Allows writes to the context object. These will be added to the output, but is implementation dependent, and I'm not 100% sure I've got it right yet, but the infrastructure is there. 4. Allows proper tests throughout this project. 5. Allows proper tests in the application too. 6. Removes the need for the handler to be public under flexlib. Flexilib handler registration now works just like everything else. Note, however, that flexilib is unique in that your handler registration function will return before the program ends. If this is important for resource cleanup, @import("build_options").build_type is your friend. 7. Request method can now be passed into console applications using -m or --method --- README.md | 38 +++-- build.zig | 56 ++----- src/console.zig | 275 +++++++++++++++++++++++++++++++++ src/flexilib.zig | 64 +++++++- src/helpers.zig | 223 -------------------------- src/interface.zig | 69 +++++++++ src/lambda.zig | 25 ++- src/test.zig | 6 + src/universal_lambda.zig | 116 ++------------ src/universal_lambda_build.zig | 68 ++++---- 10 files changed, 501 insertions(+), 439 deletions(-) create mode 100644 src/console.zig delete mode 100644 src/helpers.zig create mode 100644 src/interface.zig create mode 100644 src/test.zig diff --git a/README.md b/README.md index ef09780..54a94d2 100644 --- a/README.md +++ b/README.md @@ -62,24 +62,33 @@ try configureUniversalLambdaBuild(b, exe); ``` This will provide most of the magic functionality of the package, including -several new build steps to manage the system, and a new import to be used. +several new build steps to manage the system, and a new import to be used. For +testing, it is also advisable to add the modules to your tests by adding a line +like so: + +```zig +_ = try universal_lambda.addModules(b, main_tests); +``` **main.zig** -The build changes above will add a module called 'universal_lambda_handler'. -Add an import: +The build changes above will add several modules: + +* universal_lambda_handler: Main import, used to register your handler +* universal_lambda_interface: Contains the context type used in the handler function +* flexilib-interface: Used as a dependency of the handler. Not normally needed + +Add imports for the handler registration and interface: ```zig const universal_lambda = @import("universal_lambda_handler"); +const universal_lambda_interface = @import("universal_lambda_interface"); ``` -Add a handler to be executed. **This must be public, and named 'handler'**. -If you don't want to do that, name it whatever you want, and provide a public -const, e.g. `pub const handler=myActualFunctionName`. The handler must -follow this signature: +Add a handler to be executed. The handler must follow this signature: ```zig -pub fn handler(allocator: std.mem.Allocator, event_data: []const u8, context: universal_lambda.Context) ![]const u8 +pub fn handler(allocator: std.mem.Allocator, event_data: []const u8, context: universal_lambda_interface.Context) ![]const u8 ``` Your main function should return `!u8`. Let the package know about your handler in your main function, like so: @@ -93,12 +102,8 @@ would like to use, you may specify it. Otherwise, an appropriate allocator will be created and used. Currently this is an ArenaAllocator wrapped around an appropriate base allocator, so your handler does not require deallocation. -Note that for `flexilib` builds, the main function is ignored and the handler -is called directly. This is unique to flexilib. - A fully working example of usage is at https://git.lerch.org/lobo/universal-lambda-example/. - Usage - Building ---------------- @@ -134,9 +139,8 @@ as a standalone web server. Limitations ----------- -This is currently a minimal viable product. The biggest current limitation -is that context is not currently implemented. This is important to see -command line arguments, http headers and the like. +Limitations include standalone web server port customization and linux/aws cli requirements for Linux. -Other limitations include standalone web server port customization, main -function not called under flexilib, and linux/aws cli requirements for Linux. +Also, within the context, AWS Lambda is unable to provide proper method, target, +and headers for the request. This may be important for routing purposes. Suggestion +here is to use API Gateway and pass these parameters through the event_data content. diff --git a/build.zig b/build.zig index fdeeeb0..4ee79e7 100644 --- a/build.zig +++ b/build.zig @@ -23,17 +23,9 @@ pub fn build(b: *std.Build) !void { .target = target, .optimize = optimize, }); - const flexilib_dep = b.dependency("flexilib", .{ - .target = target, - .optimize = optimize, - }); - const flexilib_module = flexilib_dep.module("flexilib-interface"); - lib.addModule("flexilib-interface", flexilib_module); - // Because we are...well, ourselves, we'll manually override the module - // root (we are not a module here). - const ulb = @import("src/universal_lambda_build.zig"); - ulb.module_root = ""; - _ = try ulb.createOptionsModule(b, lib); + const universal_lambda = @import("src/universal_lambda_build.zig"); + universal_lambda.module_root = b.build_root.path; + _ = try universal_lambda.addModules(b, lib); // This declares intent for the library to be installed into the standard // location when the user invokes the "install" step (the default step when @@ -43,55 +35,27 @@ pub fn build(b: *std.Build) !void { // Creates a step for unit testing. This only builds the test executable // but does not run it. const main_tests = b.addTest(.{ - .root_source_file = .{ .path = "src/universal_lambda.zig" }, + .root_source_file = .{ .path = "src/test.zig" }, .target = target, .optimize = optimize, }); - _ = try ulb.createOptionsModule(b, main_tests); + _ = try universal_lambda.addModules(b, main_tests); + // _ = try ulb.createOptionsModule(b, main_tests); - main_tests.addModule("flexilib-interface", flexilib_module); + // main_tests.addModule("flexilib-interface", flexilib_module); var run_main_tests = b.addRunArtifact(main_tests); run_main_tests.skip_foreign_checks = true; - const helper_tests = b.addTest(.{ - .root_source_file = .{ .path = "src/helpers.zig" }, - .target = target, - .optimize = optimize, - }); - _ = try ulb.createOptionsModule(b, helper_tests); - // Add module - helper_tests.addAnonymousModule("universal_lambda_handler", .{ - // Source file can be anywhere on disk, does not need to be a subdirectory - .source_file = .{ .path = "src/universal_lambda.zig" }, - // We alsso need the interface module available here - .dependencies = &[_]std.Build.ModuleDependency{ - // Add options module so we can let our universal_lambda know what - // type of interface is necessary - .{ - .name = "build_options", - .module = main_tests.modules.get("build_options").?, - }, - .{ - .name = "flexilib-interface", - .module = flexilib_module, - }, - }, - }); - var run_helper_tests = b.addRunArtifact(helper_tests); - run_helper_tests.skip_foreign_checks = true; - // This creates a build step. It will be visible in the `zig build --help` menu, // and can be selected like this: `zig build test` // This will evaluate the `test` step rather than the default, which is "install". const test_step = b.step("test", "Run library tests"); test_step.dependOn(&run_main_tests.step); - test_step.dependOn(&run_helper_tests.step); - - _ = b.addModule("universal_lambda_helpers", .{ - .source_file = .{ .path = "src/helpers.zig" }, - }); } pub fn configureBuild(b: *std.Build, cs: *std.Build.Step.Compile) !void { try @import("src/universal_lambda_build.zig").configureBuild(b, cs); } +pub fn addModules(b: *std.Build, cs: *std.Build.Step.Compile) ![]const u8 { + try @import("src/universal_lambda_build.zig").addModules(b, cs); +} diff --git a/src/console.zig b/src/console.zig new file mode 100644 index 0000000..4669231 --- /dev/null +++ b/src/console.zig @@ -0,0 +1,275 @@ +//! This consists of helper functions to provide simple access using standard +//! patterns. +const std = @import("std"); +const interface = @import("universal_lambda_interface"); + +const Option = struct { + short: []const u8, + long: []const u8, +}; + +const target_option: Option = .{ .short = "t", .long = "target" }; +const url_option: Option = .{ .short = "u", .long = "url" }; +const header_option: Option = .{ .short = "h", .long = "header" }; +const method_option: Option = .{ .short = "m", .long = "method" }; + +pub fn run(allocator: ?std.mem.Allocator, event_handler: interface.HandlerFn) !u8 { + var arena = std.heap.ArenaAllocator.init(allocator orelse std.heap.page_allocator); + defer arena.deinit(); + + const aa = arena.allocator(); + + const is_test = @import("builtin").is_test; + const data = if (is_test) + test_content + else + std.io.getStdIn().reader().readAllAlloc(aa, std.math.maxInt(usize)); + // We're setting up an arena allocator. While we could use a gpa and get + // some additional safety, this is now "production" runtime, and those + // things are better handled by unit tests + var response = interface.Response.init(aa); + defer response.deinit(); + var headers = try findHeaders(aa); + defer headers.deinit(); + response.request.headers = headers.http_headers.*; + response.request.headers_owned = false; + response.request.target = try findTarget(aa); + response.request.method = try findMethod(aa); + // Note here we are throwing out the status and reason. This is to make + // the console experience less "webby" and more "consoly", at the potential + // cost of data loss for not outputting the http status/reason + const output = event_handler(aa, data, &response) catch |err| { + const err_writer = if (is_test) + test_output.writer() + else + std.io.getStdErr(); + + // Flush anything already written by the handler + try err_writer.writeAll(response.body.items); + return err; + }; + + const writer = if (is_test) + test_output.writer() + else if (response.status.class() == .success) std.io.getStdOut() else std.io.getStdErr(); + + // First flush anything written by the handler + try writer.writeAll(response.body.items); + + // Now flush the result + try writer.writeAll(output); + try writer.writeAll("\n"); + // We might have gotten an error message managed directly by the event handler + // If that's the case, we will need to report back an error code + return if (response.status.class() == .success) 0 else 1; +} + +fn findMethod(allocator: std.mem.Allocator) !std.http.Method { + // without context, we have environment variables (but for this, I think not), + // possibly event data (API Gateway does this if so configured), + // or the command line. For now we'll just look at the command line + var argIterator = try std.process.argsWithAllocator(allocator); + _ = argIterator.next(); + var is_target_option = false; + while (argIterator.next()) |arg| { + if (is_target_option) { + if (std.mem.startsWith(u8, arg, "-") or + std.mem.startsWith(u8, arg, "--")) + return error.CommandLineError; + return std.meta.stringToEnum(std.http.Method, arg) orelse return error.BadMethod; + } + if (std.mem.startsWith(u8, arg, "-" ++ method_option.short) or + std.mem.startsWith(u8, arg, "--" ++ method_option.long)) + { + // We'll search for --target=blah style first + var split = std.mem.splitSequence(u8, arg, "="); + _ = split.next(); + const rest = split.rest(); + if (split.next()) |_| + return std.meta.stringToEnum(std.http.Method, rest) orelse return error.BadMethod; + is_target_option = true; + } + } + return .GET; +} +fn findTarget(allocator: std.mem.Allocator) ![]const u8 { + // without context, we have environment variables (but for this, I think not), + // possibly event data (API Gateway does this if so configured), + // or the command line. For now we'll just look at the command line + var argIterator = try std.process.argsWithAllocator(allocator); + _ = argIterator.next(); + var is_target_option = false; + var is_url_option = false; + while (argIterator.next()) |arg| { + if (is_target_option or is_url_option) { + if (std.mem.startsWith(u8, arg, "-") or + std.mem.startsWith(u8, arg, "--")) + return error.CommandLineError; + if (is_target_option) + return arg; + return (try std.Uri.parse(arg)).path; + } + if (std.mem.startsWith(u8, arg, "-" ++ target_option.short) or + std.mem.startsWith(u8, arg, "--" ++ target_option.long)) + { + // We'll search for --target=blah style first + var split = std.mem.splitSequence(u8, arg, "="); + _ = split.next(); + const rest = split.rest(); + if (split.next()) |_| return rest; // found it + is_target_option = true; + } + if (std.mem.startsWith(u8, arg, "-" ++ url_option.short) or + std.mem.startsWith(u8, arg, "--" ++ url_option.long)) + { + // We'll search for --target=blah style first + var split = std.mem.splitSequence(u8, arg, "="); + _ = split.next(); + const rest = split.rest(); + if (split.next()) |_| return (try std.Uri.parse(rest)).path; // found it + is_url_option = true; + } + } + return "/"; +} + +pub const Headers = struct { + http_headers: *std.http.Headers, + owned: bool, + allocator: std.mem.Allocator, + + const Self = @This(); + + pub fn init(allocator: std.mem.Allocator, headers: *std.http.Headers, owned: bool) Self { + return .{ + .http_headers = headers, + .owned = owned, + .allocator = allocator, + }; + } + + pub fn deinit(self: *Self) void { + if (self.owned) { + self.http_headers.deinit(); + self.allocator.destroy(self.http_headers); + self.http_headers = undefined; + } + } +}; + +// Get headers from request. Headers will be gathered from the command line +// and include all environment variables +pub fn findHeaders(allocator: std.mem.Allocator) !Headers { + var headers = try allocator.create(std.http.Headers); + errdefer allocator.destroy(headers); + headers.allocator = allocator; + headers.list = .{}; + headers.index = .{}; + headers.owned = true; + errdefer headers.deinit(); + + // without context, we have environment variables + // possibly event data (API Gateway does this if so configured), + // or the command line. For headers, we'll prioritize command line options + // with a fallback to environment variables + const is_test = @import("builtin").is_test; + var argIterator = if (is_test) test_args.iterator(0) else std.process.argsWithAllocator(allocator); + _ = argIterator.next(); + var is_header_option = false; + while (argIterator.next()) |a| { + const arg = if (is_test) a.* else a; + if (is_header_option) { + if (std.mem.startsWith(u8, arg, "-") or + std.mem.startsWith(u8, arg, "--")) + return error.CommandLineError; + is_header_option = false; + var split = std.mem.splitSequence(u8, arg, "="); + const name = split.next().?; + try headers.append(name, split.rest()); + } + if (std.mem.startsWith(u8, arg, "-" ++ header_option.short) or + std.mem.startsWith(u8, arg, "--" ++ header_option.long)) + { + // header option forms on command line: + // -h name=value + // --header name=value + is_header_option = true; + } + } + if (is_test) return Headers.init(allocator, headers, true); + + // not found on command line. Let's check environment + var map = try std.process.getEnvMap(allocator); + defer map.deinit(); + var it = map.iterator(); + while (it.next()) |kvp| { + // Do not allow environment variables to interfere with command line + if (headers.getFirstValue(kvp.key_ptr.*) == null) + try headers.append( + kvp.key_ptr.*, + kvp.value_ptr.*, + ); + } + return Headers.init(allocator, headers, true); +} + +test { + std.testing.refAllDecls(@This()); +} + +test "can get headers" { + // const ll = std.testing.log_level; + // std.testing.log_level = .debug; + // defer std.testing.log_level = ll; + // This test complains about a leak in WASI, but in WASI, we're not running + // long processes (just command line stuff), so we don't really care about + // leaks. There doesn't seem to be a way to ignore leak detection + if (@import("builtin").os.tag == .wasi) return error.SkipZigTest; + const allocator = std.testing.allocator; + test_args = .{}; + defer test_args.deinit(allocator); + try test_args.append(allocator, "mainexe"); + try test_args.append(allocator, "-h"); + try test_args.append(allocator, "X-Foo=Bar"); + var headers = try findHeaders(allocator); + defer headers.deinit(); + try std.testing.expectEqual(@as(usize, 1), headers.http_headers.list.items.len); +} + +fn testHandler(allocator: std.mem.Allocator, event_data: []const u8, context: interface.Context) ![]const u8 { + try context.headers.append("X-custom-foo", "bar"); + try context.writeAll(event_data); + return std.fmt.allocPrint(allocator, "{d}", .{context.request.headers.list.items.len}); +} + +var test_args: std.SegmentedList([]const u8, 8) = undefined; +var test_content: []const u8 = undefined; +var test_output: std.ArrayList(u8) = undefined; +// Need to figure out how tests would work +test "handle_request" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var aa = arena.allocator(); + test_args = .{}; + defer test_args.deinit(aa); + try test_args.append(aa, "mainexe"); + try test_args.append(aa, "-t"); + try test_args.append(aa, "/hello"); + + try test_args.append(aa, "-m"); + try test_args.append(aa, "PUT"); + + try test_args.append(aa, "-h"); + try test_args.append(aa, "X-Foo=Bar"); + + test_content = " "; + test_output = std.ArrayList(u8).init(aa); + defer test_output.deinit(); + const response = try run(aa, testHandler); + try std.testing.expectEqualStrings(" 1\n", test_output.items); + try std.testing.expectEqual(@as(u8, 0), response); + // Response headers won't be visible in a console app + // try testing.expectEqual(@as(usize, 1), response.headers_len); + // try testing.expectEqualStrings("X-custom-foo", response.headers[0].name_ptr[0..response.headers[0].name_len]); + // try testing.expectEqualStrings("bar", response.headers[0].value_ptr[0..response.headers[0].value_len]); +} diff --git a/src/flexilib.zig b/src/flexilib.zig index e5d6958..c3780d4 100644 --- a/src/flexilib.zig +++ b/src/flexilib.zig @@ -1,10 +1,11 @@ const std = @import("std"); const interface = @import("flexilib-interface"); +const universal_lambda_interface = @import("universal_lambda_interface"); const testing = std.testing; const log = std.log.scoped(.@"main-lib"); -const client_handler = @import("flexilib_handler"); +const Application = if (@import("builtin").is_test) @This() else @import("flexilib_handler"); // The main program will look for exports during the request lifecycle: // zigInit (optional): called at the beginning of a request, includes pointer to an allocator @@ -63,13 +64,57 @@ fn handleRequest(allocator: std.mem.Allocator, response: *interface.ZigResponse) // setup var response_writer = response.body.writer(); // dispatch to our actual handler - try response_writer.writeAll(try client_handler.handler( + if (handler == null) _ = try Application.main(); + std.debug.assert(handler != null); + // setup response + var ul_response = universal_lambda_interface.Response.init(allocator); + defer ul_response.deinit(); + ul_response.request.target = response.request.target; + ul_response.request.headers = response.request.headers; + ul_response.request.method = std.meta.stringToEnum(std.http.Method, response.request.method) orelse std.http.Method.GET; + const response_content = try handler.?( allocator, response.request.content, - .{ - .flexilib = response, - }, - )); + &ul_response, + ); + // Copy any headers + for (ul_response.headers.list.items) |entry| { + try response.headers.append(entry.name, entry.value); + } + // Anything manually written goes first + try response_writer.writeAll(ul_response.body.items); + // Now we right the official body (response from handler) + try response_writer.writeAll(response_content); +} + +pub fn run(allocator: ?std.mem.Allocator, event_handler: universal_lambda_interface.HandlerFn) !u8 { + _ = allocator; + register(event_handler); + return 0; +} + +var handler: ?universal_lambda_interface.HandlerFn = null; +/// Registers a handler function with flexilib +pub fn register(h: universal_lambda_interface.HandlerFn) void { + handler = h; +} +pub fn main() !u8 { + // should only be called under test! + // Flexilib runs under a DLL. So the plan is: + // 1. dll calls handle_request + // 2. handle_request discovers, through build, where it came from + // 3. handle_request calls main + // 4. main, in the application, calls run, thinking it's a console app + // 5. run, calls back to universal lambda, which then calls back here to register + // 6. register, registers the handler. It will need to be up to main() to recognize + // build_options and look for flexilib if they're doing something fancy + register(testHandler); + return 0; +} +fn testHandler(allocator: std.mem.Allocator, event_data: []const u8, context: @import("universal_lambda_interface").Context) ![]const u8 { + try context.headers.append("X-custom-foo", "bar"); + try context.writeAll(event_data); + return std.fmt.allocPrint(allocator, "{d}", .{context.request.headers.list.items.len}); } // Need to figure out how tests would work test "handle_request" { @@ -86,13 +131,16 @@ test "handle_request" { var req = interface.Request{ .method = @ptrCast(@constCast("GET".ptr)), .method_len = 3, - .content = @ptrCast(@constCast("GET".ptr)), - .content_len = 3, + .content = @ptrCast(@constCast(" ".ptr)), + .content_len = 1, .headers = headers.ptr, .headers_len = 1, + .target = @ptrCast(@constCast("/".ptr)), + .target_len = 1, }; const response = handle_request(&req).?; try testing.expectEqualStrings(" 1", response.ptr[0..response.len]); + try testing.expectEqual(@as(usize, 1), response.headers_len); try testing.expectEqualStrings("X-custom-foo", response.headers[0].name_ptr[0..response.headers[0].name_len]); try testing.expectEqualStrings("bar", response.headers[0].value_ptr[0..response.headers[0].value_len]); } diff --git a/src/helpers.zig b/src/helpers.zig deleted file mode 100644 index 8a1cf8a..0000000 --- a/src/helpers.zig +++ /dev/null @@ -1,223 +0,0 @@ -//! This consists of helper functions to provide simple access using standard -//! patterns. -const std = @import("std"); -const universal_lambda = @import("universal_lambda_handler"); - -const Option = struct { - short: []const u8, - long: []const u8, -}; - -const target_option: Option = .{ .short = "t", .long = "target" }; -const url_option: Option = .{ .short = "u", .long = "url" }; -const header_option: Option = .{ .short = "h", .long = "header" }; - -/// Finds the "target" for this request. In a web request, this is the path -/// used for the request (e.g. "/" vs "/admin"). In a non-web environment, -/// this is determined by a command line option -t or --target. Note that -/// AWS API Gateway is not supported here...this is a configured thing in -/// API Gateway, and so is pretty situational. It also would be presented in -/// event data rather than context -pub fn findTarget(allocator: std.mem.Allocator, context: universal_lambda.Context) ![]const u8 { - switch (context) { - .web_request => |res| return res.request.target, - .flexilib => |ctx| return ctx.request.target, - .none => return findTargetWithoutContext(allocator), - } -} - -pub fn setStatus(context: universal_lambda.Context, status: std.http.Status, reason: ?[]const u8) void { - switch (context) { - .web_request => |res| { - res.status = status; - res.reason = reason; - }, - .flexilib => |res| { - res.status = status; - res.reason = reason; - }, - .none => |res| { - res.status = status; - res.reason = reason; - }, - } -} -pub fn writeAll(context: universal_lambda.Context, data: []const u8) !void { - switch (context) { - .web_request => |res| return try res.writeAll(data), - .flexilib => |res| return try res.writeAll(data), - .none => |res| return try res.writeAll(data), - } -} -fn writeFn(context: universal_lambda.Context, data: []const u8) WriteError!usize { - switch (context) { - .web_request => |res| return res.write(data), - .flexilib => |res| return res.write(data), - .none => |res| return res.write(data), - } -} -pub const WriteError = error{OutOfMemory}; - -pub fn writer(context: universal_lambda.Context) std.io.Writer(universal_lambda.Context, WriteError, writeFn) { - return .{ .context = context }; -} - -fn findTargetWithoutContext(allocator: std.mem.Allocator) ![]const u8 { - // without context, we have environment variables (but for this, I think not), - // possibly event data (API Gateway does this if so configured), - // or the command line. For now we'll just look at the command line - var argIterator = try std.process.argsWithAllocator(allocator); - _ = argIterator.next(); - var is_target_option = false; - var is_url_option = false; - while (argIterator.next()) |arg| { - if (is_target_option or is_url_option) { - if (std.mem.startsWith(u8, arg, "-") or - std.mem.startsWith(u8, arg, "--")) - { - // bad user input, but we're not returning errors here - return "/"; - } - if (is_target_option) - return arg; - return (try std.Uri.parse(arg)).path; - } - if (std.mem.startsWith(u8, arg, "-" ++ target_option.short) or - std.mem.startsWith(u8, arg, "--" ++ target_option.long)) - { - // We'll search for --target=blah style first - var split = std.mem.splitSequence(u8, arg, "="); - _ = split.next(); - const rest = split.rest(); - if (split.next()) |_| return rest; // found it - is_target_option = true; - } - if (std.mem.startsWith(u8, arg, "-" ++ url_option.short) or - std.mem.startsWith(u8, arg, "--" ++ url_option.long)) - { - // We'll search for --target=blah style first - var split = std.mem.splitSequence(u8, arg, "="); - _ = split.next(); - const rest = split.rest(); - if (split.next()) |_| return (try std.Uri.parse(rest)).path; // found it - is_url_option = true; - } - } - return "/"; -} - -pub const Headers = struct { - http_headers: *std.http.Headers, - owned: bool, - allocator: std.mem.Allocator, - - const Self = @This(); - - pub fn init(allocator: std.mem.Allocator, headers: *std.http.Headers, owned: bool) Self { - return .{ - .http_headers = headers, - .owned = owned, - .allocator = allocator, - }; - } - - pub fn deinit(self: *Self) void { - if (self.owned) { - self.http_headers.deinit(); - self.allocator.destroy(self.http_headers); - self.http_headers = undefined; - } - } -}; - -/// Get headers from request. If Lambda is not in a web context, headers -/// will be gathered from the command line and include all environment variables -pub fn allHeaders(allocator: std.mem.Allocator, context: universal_lambda.Context) !Headers { - switch (context) { - .web_request => |res| return Headers.init(allocator, &res.request.headers, false), - .flexilib => |ctx| return Headers.init(allocator, &ctx.request.headers, false), - .none => return headersWithoutContext(allocator), - } -} - -fn headersWithoutContext(allocator: std.mem.Allocator) !Headers { - var headers = try allocator.create(std.http.Headers); - errdefer allocator.destroy(headers); - headers.allocator = allocator; - headers.list = .{}; - headers.index = .{}; - headers.owned = true; - errdefer headers.deinit(); - - // without context, we have environment variables - // possibly event data (API Gateway does this if so configured), - // or the command line. For headers, we'll prioritize command line options - // with a fallback to environment variables - var argIterator = try std.process.argsWithAllocator(allocator); - _ = argIterator.next(); - var is_header_option = false; - while (argIterator.next()) |arg| { - if (is_header_option) { - if (std.mem.startsWith(u8, arg, "-") or - std.mem.startsWith(u8, arg, "--")) - { - return error.CommandLineError; - } - is_header_option = false; - var split = std.mem.splitSequence(u8, arg, "="); - const name = split.next().?; - try headers.append(name, split.rest()); - } - if (std.mem.startsWith(u8, arg, "-" ++ header_option.short) or - std.mem.startsWith(u8, arg, "--" ++ header_option.long)) - { - // header option forms on command line: - // -h name=value - // --header name=value - is_header_option = true; - } - } - - // not found on command line. Let's check environment - var map = try std.process.getEnvMap(allocator); - defer map.deinit(); - var it = map.iterator(); - while (it.next()) |kvp| { - // Do not allow environment variables to interfere with command line - if (headers.getFirstValue(kvp.key_ptr.*) == null) - try headers.append( - kvp.key_ptr.*, - kvp.value_ptr.*, - ); - } - return Headers.init(allocator, headers, true); // nowhere to be found -} - -test { - std.testing.refAllDecls(@This()); -} - -test "can get headers" { - // This test complains about a leak in WASI, but in WASI, we're not running - // long processes (just command line stuff), so we don't really care about - // leaks. There doesn't seem to be a way to ignore leak detection - if (@import("builtin").os.tag == .wasi) return error.SkipZigTest; - const allocator = std.testing.allocator; - var response = universal_lambda.Response.init(allocator); - const context = universal_lambda.Context{ - .none = &response, - }; - var headers = try allHeaders(allocator, context); - defer headers.deinit(); - try std.testing.expect(headers.http_headers.list.items.len > 0); -} -test "can write" { - const allocator = std.testing.allocator; - var response = universal_lambda.Response.init(allocator); - const context = universal_lambda.Context{ - .none = &response, - }; - const my_writer = writer(context); - try my_writer.writeAll("hello"); - try response.finish(); -} diff --git a/src/interface.zig b/src/interface.zig new file mode 100644 index 0000000..206ece4 --- /dev/null +++ b/src/interface.zig @@ -0,0 +1,69 @@ +const std = @import("std"); + +pub const HandlerFn = *const fn (std.mem.Allocator, []const u8, Context) anyerror![]const u8; + +pub const Response = struct { + allocator: std.mem.Allocator, + headers: std.http.Headers, + headers_owned: bool = true, + status: std.http.Status = .ok, + reason: ?[]const u8 = null, + /// client request. Note that in AWS lambda, all these are left at default. + /// It is currently up to you to work through a) if API Gateway is set up, + /// and b) how that gets parsed into event data. API Gateway has the ability + /// to severely muck with inbound data and we are unprepared to deal with + /// that here + request: struct { + target: []const u8 = "/", + headers: std.http.Headers, + headers_owned: bool = true, + method: std.http.Method = .GET, + }, + body: std.ArrayList(u8), + + // The problem we face is this: + // + // exe_run, cloudflare (wasi) are basically console apps + // flexilib is a web server (wierd one) + // standalone web server is a web server + // aws lambda is a web client + // + // void will work for exe_run/cloudflare + // ZigResponse works out of the box for flexilib - the lifecycle problem is + // handled in the interface + // + // aws lambda - need to investigate + // standalone web server...needs to spool + pub fn init(allocator: std.mem.Allocator) Response { + return .{ + .allocator = allocator, + .headers = .{ .allocator = allocator }, + .request = .{ + .headers = .{ .allocator = allocator }, + }, + .body = std.ArrayList(u8).init(allocator), + }; + } + pub fn write(res: *Response, bytes: []const u8) !usize { + return res.body.writer().write(bytes); + } + + pub fn writeAll(res: *Response, bytes: []const u8) !void { + return res.body.writer().writeAll(bytes); + } + + pub fn writer(res: *Response) std.io.Writer(*Response, error{OutOfMemory}, writeFn) { + return .{ .context = res }; + } + + fn writeFn(context: *Response, data: []const u8) error{OutOfMemory}!usize { + return try context.write(data); + } + pub fn deinit(res: *Response) void { + res.body.deinit(); + if (res.headers_owned) res.headers.deinit(); + if (res.request.headers_owned) res.request.headers.deinit(); + } +}; + +pub const Context = *Response; diff --git a/src/lambda.zig b/src/lambda.zig index ebe9be4..d5b09c2 100644 --- a/src/lambda.zig +++ b/src/lambda.zig @@ -1,9 +1,10 @@ const std = @import("std"); const builtin = @import("builtin"); -const HandlerFn = @import("universal_lambda.zig").HandlerFn; -const Context = @import("universal_lambda.zig").Context; -const UniversalLambdaResponse = @import("universal_lambda.zig").Response; +const universal_lambda_interface = @import("universal_lambda_interface"); +const HandlerFn = universal_lambda_interface.HandlerFn; +const Context = universal_lambda_interface.Context; +const UniversalLambdaResponse = universal_lambda_interface.Response; const log = std.log.scoped(.lambda); @@ -70,15 +71,23 @@ pub fn run(allocator: ?std.mem.Allocator, event_handler: HandlerFn) !u8 { // TOD defer ev.?.deinit(); // Lambda does not have context, just environment variables. API Gateway // might be configured to pass in lots of context, but this comes through - // event data, not context. + // event data, not context. In this case, we lose: + // + // request headers + // request method + // request target var response = UniversalLambdaResponse.init(req_allocator); - response.output_file = std.io.getStdOut(); - const event_response = event_handler(req_allocator, event.event_data, .{ .none = &response }) catch |err| { - response.finish() catch unreachable; + defer response.deinit(); + const body_writer = std.io.getStdOut(); + const event_response = event_handler(req_allocator, event.event_data, &response) catch |err| { + body_writer.writeAll(response.body.items) catch unreachable; event.reportError(@errorReturnTrace(), err, lambda_runtime_uri) catch unreachable; continue; }; - response.finish() catch unreachable; + // No error during handler. Write anything sent to body to stdout instead + // I'm not totally sure this is the right behavior as it is a little inconsistent + // (flexilib and console both write to the same io stream as the main output) + body_writer.writeAll(response.body.items) catch unreachable; event.postResponse(lambda_runtime_uri, event_response) catch |err| { event.reportError(@errorReturnTrace(), err, lambda_runtime_uri) catch unreachable; continue; diff --git a/src/test.zig b/src/test.zig new file mode 100644 index 0000000..bd995a4 --- /dev/null +++ b/src/test.zig @@ -0,0 +1,6 @@ +const std = @import("std"); + +test { + std.testing.refAllDecls(@This()); + std.testing.refAllDecls(@import("universal_lambda.zig")); +} diff --git a/src/universal_lambda.zig b/src/universal_lambda.zig index d390f91..b42af31 100644 --- a/src/universal_lambda.zig +++ b/src/universal_lambda.zig @@ -1,120 +1,32 @@ const std = @import("std"); const build_options = @import("build_options"); const flexilib = @import("flexilib-interface"); -pub const HandlerFn = *const fn (std.mem.Allocator, []const u8, Context) anyerror![]const u8; +const interface = @import("universal_lambda_interface"); const log = std.log.scoped(.universal_lambda); -pub const Response = struct { - allocator: std.mem.Allocator, - headers: std.http.Headers, - output_file: ?std.fs.File = null, - status: std.http.Status = .ok, - reason: ?[]const u8 = null, - request: struct { - // TODO: We will likely end up needing method here at some point... - target: []const u8, - headers: std.http.Headers, - }, - al: std.ArrayList(u8), - - pub fn init(allocator: std.mem.Allocator) Response { - return .{ - .allocator = allocator, - .headers = .{ .allocator = allocator }, - .request = .{ - .target = "/", - .headers = .{ .allocator = allocator }, - }, - .al = std.ArrayList(u8).init(allocator), - }; - } - pub fn write(res: *Response, bytes: []const u8) !usize { - return res.al.writer().write(bytes); - } - - pub fn writeAll(res: *Response, bytes: []const u8) !void { - return res.al.writer().writeAll(bytes); - } - - pub fn writer(res: *Response) std.io.Writer { - return res.al.writer().writer(); - } - - pub fn finish(res: *Response) !void { - if (res.output_file) |f| { - try f.writer().writeAll(res.al.items); - } - res.al.deinit(); - } -}; - -pub const Context = union(enum) { - web_request: switch (build_options.build_type) { - .exe_run, .cloudflare => *Response, - else => *std.http.Server.Response, - }, - flexilib: *flexilib.ZigResponse, - none: *Response, -}; - const runFn = blk: { switch (build_options.build_type) { .awslambda => break :blk @import("lambda.zig").run, .standalone_server => break :blk runStandaloneServer, - .exe_run, .cloudflare => break :blk runExe, - else => @compileError("Provider interface for " ++ @tagName(build_options.build_type) ++ " has not yet been implemented"), + .flexilib => break :blk @import("flexilib.zig").run, + .exe_run, .cloudflare => break :blk @import("console.zig").run, } }; -fn deinit() void { - // if (client) |*c| c.deinit(); - // client = null; -} /// Starts the universal lambda framework. Handler will be called when an event is processing. /// Depending on the serverless system used, from a practical sense, this may not return. /// /// If an allocator is not provided, an approrpriate allocator will be selected and used /// This function is intended to loop infinitely. If not used in this manner, /// make sure to call the deinit() function -pub fn run(allocator: ?std.mem.Allocator, event_handler: HandlerFn) !u8 { // TODO: remove inferred error set? +pub fn run(allocator: ?std.mem.Allocator, event_handler: interface.HandlerFn) !u8 { // TODO: remove inferred error set? return try runFn(allocator, event_handler); } -fn runExe(allocator: ?std.mem.Allocator, event_handler: HandlerFn) !u8 { - var arena = std.heap.ArenaAllocator.init(allocator orelse std.heap.page_allocator); - defer arena.deinit(); - - const aa = arena.allocator(); - - const data = try std.io.getStdIn().reader().readAllAlloc(aa, std.math.maxInt(usize)); - // We're setting up an arena allocator. While we could use a gpa and get - // some additional safety, this is now "production" runtime, and those - // things are better handled by unit tests - var response = Response.init(aa); - - // Note here we are throwing out the status and reason. This is to make - // the console experience less "webby" and more "consoly", at the potential - // cost of data loss for not outputting the http status/reason - const output = event_handler(aa, data, .{ .none = &response }) catch |err| { - response.output_file = std.io.getStdErr(); - try response.finish(); - return err; - }; - - response.output_file = if (response.status.class() == .success) std.io.getStdOut() else std.io.getStdErr(); - const writer = response.output_file.?.writer(); - try response.finish(); - try writer.writeAll(output); - try writer.writeAll("\n"); - // We might have gotten an error message managed directly by the event handler - // If that's the case, we will need to report back an error code - return if (response.status.class() == .success) 0 else 1; -} - /// Will create a web server and marshall all requests back to our event handler /// To keep things simple, we'll have this on a single thread, at least for now -fn runStandaloneServer(allocator: ?std.mem.Allocator, event_handler: HandlerFn) !u8 { +fn runStandaloneServer(allocator: ?std.mem.Allocator, event_handler: interface.HandlerFn) !u8 { const alloc = allocator orelse std.heap.page_allocator; var arena = std.heap.ArenaAllocator.init(alloc); @@ -152,7 +64,7 @@ fn runStandaloneServer(allocator: ?std.mem.Allocator, event_handler: HandlerFn) return 0; } -fn processRequest(aa: std.mem.Allocator, server: *std.http.Server, event_handler: HandlerFn) !void { +fn processRequest(aa: std.mem.Allocator, server: *std.http.Server, event_handler: interface.HandlerFn) !void { var res = try server.accept(.{ .allocator = aa }); defer { _ = res.reset(); @@ -174,7 +86,7 @@ fn processRequest(aa: std.mem.Allocator, server: *std.http.Server, event_handler // no need to free - will be handled by arena response_bytes = event_handler(aa, body, .{ .web_request = &res }) catch |e| brk: { - res.status = .internal_server_error; + if (res.status.class() == .success) res.status = .internal_server_error; // TODO: more about this particular request log.err("Unexpected error from executor processing request: {any}", .{e}); if (@errorReturnTrace()) |trace| { @@ -188,15 +100,19 @@ fn processRequest(aa: std.mem.Allocator, server: *std.http.Server, event_handler _ = try res.writer().writeAll(response_bytes); try res.finish(); } - test { - std.testing.refAllDecls(@This()); // standalone, standalone web server + std.testing.refAllDecls(@This()); // if (builtin.os.tag == .wasi) return error.SkipZigTest; if (@import("builtin").os.tag != .wasi) { - std.testing.refAllDecls(@import("lambda.zig")); // lambda + // these use http + std.testing.refAllDecls(@import("lambda.zig")); std.testing.refAllDecls(@import("cloudflaredeploy.zig")); std.testing.refAllDecls(@import("CloudflareDeployStep.zig")); } - // TODO: re-enable - // std.testing.refAllDecls(@import("flexilib.zig")); // flexilib + std.testing.refAllDecls(@import("console.zig")); + std.testing.refAllDecls(@import("flexilib.zig")); + + // The following do not currently have tests + + // TODO: Do we want build files here too? } diff --git a/src/universal_lambda_build.zig b/src/universal_lambda_build.zig index 837bf38..43dff52 100644 --- a/src/universal_lambda_build.zig +++ b/src/universal_lambda_build.zig @@ -16,17 +16,25 @@ pub var module_root: ?[]const u8 = null; pub fn configureBuild(b: *std.Build, cs: *std.Build.Step.Compile) !void { const function_name = b.option([]const u8, "function-name", "Function name for Lambda [zig-fn]") orelse "zig-fn"; + + const file_location = addModules(b, cs); + + // Add steps + try @import("lambda_build.zig").configureBuild(b, cs, function_name); + try @import("cloudflare_build.zig").configureBuild(b, cs, function_name); + try @import("flexilib_build.zig").configureBuild(b, cs, file_location); + try @import("standalone_server_build.zig").configureBuild(b, cs); +} + +/// Add modules +/// +/// We will create the following modules for downstream consumption: +/// +/// * build_options +/// * flexilib-interface +/// * universal_lambda_handler +pub fn addModules(b: *std.Build, cs: *std.Build.Step.Compile) ![]const u8 { const file_location = try findFileLocation(b); - ///////////////////////////////////////////////////////////////////////// - // Add modules - // - // We will create the following modules for downstream consumption: - // - // * build_options - // * flexilib-interface - // * universal_lambda_handler - // - ///////////////////////////////////////////////////////////////////////// const options_module = try createOptionsModule(b, cs); // We need to add the interface module here as well, so universal_lambda.zig @@ -43,6 +51,11 @@ pub fn configureBuild(b: *std.Build, cs: *std.Build.Step.Compile) !void { const flexilib_module = flexilib_dep.module("flexilib-interface"); // Make the interface available for consumption cs.addModule("flexilib-interface", flexilib_module); + cs.addAnonymousModule("universal_lambda_interface", .{ + .source_file = .{ .path = b.pathJoin(&[_][]const u8{ file_location, "interface.zig" }) }, + // We alsso need the interface module available here + .dependencies = &[_]std.Build.ModuleDependency{}, + }); // Add module cs.addAnonymousModule("universal_lambda_handler", .{ // Source file can be anywhere on disk, does not need to be a subdirectory @@ -59,36 +72,13 @@ pub fn configureBuild(b: *std.Build, cs: *std.Build.Step.Compile) !void { .name = "flexilib-interface", .module = flexilib_module, }, - }, - }); - - cs.addAnonymousModule("universal_lambda_helpers", .{ - // Source file can be anywhere on disk, does not need to be a subdirectory - .source_file = .{ .path = b.pathJoin(&[_][]const u8{ file_location, "helpers.zig" }) }, - // We alsso need the interface module available here - .dependencies = &[_]std.Build.ModuleDependency{ - // Add options module so we can let our universal_lambda know what - // type of interface is necessary .{ - .name = "build_options", - .module = options_module, - }, - .{ - .name = "flexilib-interface", - .module = flexilib_module, - }, - .{ - .name = "universal_lambda_handler", - .module = cs.modules.get("universal_lambda_handler").?, + .name = "universal_lambda_interface", + .module = cs.modules.get("universal_lambda_interface").?, }, }, }); - - // Add steps - try @import("lambda_build.zig").configureBuild(b, cs, function_name); - try @import("cloudflare_build.zig").configureBuild(b, cs, function_name); - try @import("flexilib_build.zig").configureBuild(b, cs, file_location); - try @import("standalone_server_build.zig").configureBuild(b, cs); + return file_location; } /// This function relies on internal implementation of the build runner @@ -116,7 +106,11 @@ pub fn configureBuild(b: *std.Build, cs: *std.Build.Step.Compile) !void { /// to pull from a download location and update hashes every time we change fn findFileLocation(b: *std.Build) ![]const u8 { if (module_root) |r| return b.pathJoin(&[_][]const u8{ r, "src" }); - const build_root = b.option([]const u8, "universal_lambda_build_root", "Build root for universal lambda (development of universal lambda only)"); + const build_root = b.option( + []const u8, + "universal_lambda_build_root", + "Build root for universal lambda (development of universal lambda only)", + ); if (build_root) |br| { return b.pathJoin(&[_][]const u8{ br, "src" }); }