universal-lambda-zig/src/console.zig
Emil Lerch dcf00d8460
All checks were successful
AWS-Zig Build / build-zig-0.11.0-amd64-host (push) Successful in 1m50s
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
2023-10-24 23:45:12 -07:00

276 lines
11 KiB
Zig

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