rework context
All checks were successful
AWS-Zig Build / build-zig-0.11.0-amd64-host (push) Successful in 1m50s

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
This commit is contained in:
Emil Lerch 2023-10-24 23:45:08 -07:00
parent 2915453c1b
commit dcf00d8460
Signed by: lobo
GPG Key ID: A7B62D657EF764F8
10 changed files with 501 additions and 439 deletions

View File

@ -62,24 +62,33 @@ try configureUniversalLambdaBuild(b, exe);
``` ```
This will provide most of the magic functionality of the package, including 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** **main.zig**
The build changes above will add a module called 'universal_lambda_handler'. The build changes above will add several modules:
Add an import:
* 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 ```zig
const universal_lambda = @import("universal_lambda_handler"); 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'**. Add a handler to be executed. The handler must follow this signature:
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:
```zig ```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: 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 will be created and used. Currently this is an ArenaAllocator wrapped around
an appropriate base allocator, so your handler does not require deallocation. 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/. A fully working example of usage is at https://git.lerch.org/lobo/universal-lambda-example/.
Usage - Building Usage - Building
---------------- ----------------
@ -134,9 +139,8 @@ as a standalone web server.
Limitations Limitations
----------- -----------
This is currently a minimal viable product. The biggest current limitation Limitations include standalone web server port customization and linux/aws cli requirements for Linux.
is that context is not currently implemented. This is important to see
command line arguments, http headers and the like.
Other limitations include standalone web server port customization, main Also, within the context, AWS Lambda is unable to provide proper method, target,
function not called under flexilib, and linux/aws cli requirements for Linux. 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.

View File

@ -23,17 +23,9 @@ pub fn build(b: *std.Build) !void {
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
}); });
const flexilib_dep = b.dependency("flexilib", .{ const universal_lambda = @import("src/universal_lambda_build.zig");
.target = target, universal_lambda.module_root = b.build_root.path;
.optimize = optimize, _ = try universal_lambda.addModules(b, lib);
});
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);
// This declares intent for the library to be installed into the standard // This declares intent for the library to be installed into the standard
// location when the user invokes the "install" step (the default step when // 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 // Creates a step for unit testing. This only builds the test executable
// but does not run it. // but does not run it.
const main_tests = b.addTest(.{ const main_tests = b.addTest(.{
.root_source_file = .{ .path = "src/universal_lambda.zig" }, .root_source_file = .{ .path = "src/test.zig" },
.target = target, .target = target,
.optimize = optimize, .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); var run_main_tests = b.addRunArtifact(main_tests);
run_main_tests.skip_foreign_checks = true; 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, // This creates a build step. It will be visible in the `zig build --help` menu,
// and can be selected like this: `zig build test` // and can be selected like this: `zig build test`
// This will evaluate the `test` step rather than the default, which is "install". // This will evaluate the `test` step rather than the default, which is "install".
const test_step = b.step("test", "Run library tests"); const test_step = b.step("test", "Run library tests");
test_step.dependOn(&run_main_tests.step); 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 { pub fn configureBuild(b: *std.Build, cs: *std.Build.Step.Compile) !void {
try @import("src/universal_lambda_build.zig").configureBuild(b, cs); 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);
}

275
src/console.zig Normal file
View File

@ -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]);
}

View File

@ -1,10 +1,11 @@
const std = @import("std"); const std = @import("std");
const interface = @import("flexilib-interface"); const interface = @import("flexilib-interface");
const universal_lambda_interface = @import("universal_lambda_interface");
const testing = std.testing; const testing = std.testing;
const log = std.log.scoped(.@"main-lib"); 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: // 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 // 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 // setup
var response_writer = response.body.writer(); var response_writer = response.body.writer();
// dispatch to our actual handler // 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, allocator,
response.request.content, response.request.content,
.{ &ul_response,
.flexilib = 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 // Need to figure out how tests would work
test "handle_request" { test "handle_request" {
@ -86,13 +131,16 @@ test "handle_request" {
var req = interface.Request{ var req = interface.Request{
.method = @ptrCast(@constCast("GET".ptr)), .method = @ptrCast(@constCast("GET".ptr)),
.method_len = 3, .method_len = 3,
.content = @ptrCast(@constCast("GET".ptr)), .content = @ptrCast(@constCast(" ".ptr)),
.content_len = 3, .content_len = 1,
.headers = headers.ptr, .headers = headers.ptr,
.headers_len = 1, .headers_len = 1,
.target = @ptrCast(@constCast("/".ptr)),
.target_len = 1,
}; };
const response = handle_request(&req).?; const response = handle_request(&req).?;
try testing.expectEqualStrings(" 1", response.ptr[0..response.len]); 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("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]); try testing.expectEqualStrings("bar", response.headers[0].value_ptr[0..response.headers[0].value_len]);
} }

View File

@ -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();
}

69
src/interface.zig Normal file
View File

@ -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;

View File

@ -1,9 +1,10 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const HandlerFn = @import("universal_lambda.zig").HandlerFn; const universal_lambda_interface = @import("universal_lambda_interface");
const Context = @import("universal_lambda.zig").Context; const HandlerFn = universal_lambda_interface.HandlerFn;
const UniversalLambdaResponse = @import("universal_lambda.zig").Response; const Context = universal_lambda_interface.Context;
const UniversalLambdaResponse = universal_lambda_interface.Response;
const log = std.log.scoped(.lambda); 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(); defer ev.?.deinit();
// Lambda does not have context, just environment variables. API Gateway // Lambda does not have context, just environment variables. API Gateway
// might be configured to pass in lots of context, but this comes through // 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); var response = UniversalLambdaResponse.init(req_allocator);
response.output_file = std.io.getStdOut(); defer response.deinit();
const event_response = event_handler(req_allocator, event.event_data, .{ .none = &response }) catch |err| { const body_writer = std.io.getStdOut();
response.finish() catch unreachable; 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; event.reportError(@errorReturnTrace(), err, lambda_runtime_uri) catch unreachable;
continue; 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.postResponse(lambda_runtime_uri, event_response) catch |err| {
event.reportError(@errorReturnTrace(), err, lambda_runtime_uri) catch unreachable; event.reportError(@errorReturnTrace(), err, lambda_runtime_uri) catch unreachable;
continue; continue;

6
src/test.zig Normal file
View File

@ -0,0 +1,6 @@
const std = @import("std");
test {
std.testing.refAllDecls(@This());
std.testing.refAllDecls(@import("universal_lambda.zig"));
}

View File

@ -1,120 +1,32 @@
const std = @import("std"); const std = @import("std");
const build_options = @import("build_options"); const build_options = @import("build_options");
const flexilib = @import("flexilib-interface"); 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); 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: { const runFn = blk: {
switch (build_options.build_type) { switch (build_options.build_type) {
.awslambda => break :blk @import("lambda.zig").run, .awslambda => break :blk @import("lambda.zig").run,
.standalone_server => break :blk runStandaloneServer, .standalone_server => break :blk runStandaloneServer,
.exe_run, .cloudflare => break :blk runExe, .flexilib => break :blk @import("flexilib.zig").run,
else => @compileError("Provider interface for " ++ @tagName(build_options.build_type) ++ " has not yet been implemented"), .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. /// 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. /// 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 /// 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, /// This function is intended to loop infinitely. If not used in this manner,
/// make sure to call the deinit() function /// 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); 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 /// 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 /// 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; const alloc = allocator orelse std.heap.page_allocator;
var arena = std.heap.ArenaAllocator.init(alloc); var arena = std.heap.ArenaAllocator.init(alloc);
@ -152,7 +64,7 @@ fn runStandaloneServer(allocator: ?std.mem.Allocator, event_handler: HandlerFn)
return 0; 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 }); var res = try server.accept(.{ .allocator = aa });
defer { defer {
_ = res.reset(); _ = 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 // no need to free - will be handled by arena
response_bytes = event_handler(aa, body, .{ .web_request = &res }) catch |e| brk: { 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 // TODO: more about this particular request
log.err("Unexpected error from executor processing request: {any}", .{e}); log.err("Unexpected error from executor processing request: {any}", .{e});
if (@errorReturnTrace()) |trace| { 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.writer().writeAll(response_bytes);
try res.finish(); try res.finish();
} }
test { test {
std.testing.refAllDecls(@This()); // standalone, standalone web server std.testing.refAllDecls(@This());
// if (builtin.os.tag == .wasi) return error.SkipZigTest; // if (builtin.os.tag == .wasi) return error.SkipZigTest;
if (@import("builtin").os.tag != .wasi) { 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("cloudflaredeploy.zig"));
std.testing.refAllDecls(@import("CloudflareDeployStep.zig")); std.testing.refAllDecls(@import("CloudflareDeployStep.zig"));
} }
// TODO: re-enable std.testing.refAllDecls(@import("console.zig"));
// std.testing.refAllDecls(@import("flexilib.zig")); // flexilib std.testing.refAllDecls(@import("flexilib.zig"));
// The following do not currently have tests
// TODO: Do we want build files here too?
} }

View File

@ -16,17 +16,25 @@ pub var module_root: ?[]const u8 = null;
pub fn configureBuild(b: *std.Build, cs: *std.Build.Step.Compile) !void { 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 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); 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); const options_module = try createOptionsModule(b, cs);
// We need to add the interface module here as well, so universal_lambda.zig // 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"); const flexilib_module = flexilib_dep.module("flexilib-interface");
// Make the interface available for consumption // Make the interface available for consumption
cs.addModule("flexilib-interface", flexilib_module); 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 // Add module
cs.addAnonymousModule("universal_lambda_handler", .{ cs.addAnonymousModule("universal_lambda_handler", .{
// Source file can be anywhere on disk, does not need to be a subdirectory // 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", .name = "flexilib-interface",
.module = flexilib_module, .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", .name = "universal_lambda_interface",
.module = options_module, .module = cs.modules.get("universal_lambda_interface").?,
},
.{
.name = "flexilib-interface",
.module = flexilib_module,
},
.{
.name = "universal_lambda_handler",
.module = cs.modules.get("universal_lambda_handler").?,
}, },
}, },
}); });
return file_location;
// 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);
} }
/// This function relies on internal implementation of the build runner /// 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 /// to pull from a download location and update hashes every time we change
fn findFileLocation(b: *std.Build) ![]const u8 { fn findFileLocation(b: *std.Build) ![]const u8 {
if (module_root) |r| return b.pathJoin(&[_][]const u8{ r, "src" }); 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| { if (build_root) |br| {
return b.pathJoin(&[_][]const u8{ br, "src" }); return b.pathJoin(&[_][]const u8{ br, "src" });
} }