diff --git a/README.md b/README.md index 4fef9b9..6dd8e36 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Crowbar +![Logo](logo.svg) TODO: Write a description here diff --git a/logo.svg b/logo.svg new file mode 100644 index 0000000..4ada783 --- /dev/null +++ b/logo.svg @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + Crowbar + + + + diff --git a/spec/crowbar_spec.cr b/spec/crowbar_spec.cr new file mode 100644 index 0000000..75050ca --- /dev/null +++ b/spec/crowbar_spec.cr @@ -0,0 +1,230 @@ +require "./spec_helper" + +describe Crowbar do + describe "handle_events buffered overload" do + it "deserializes the events according to the event type" do + sent_event = %({"result": 10}) + + LambdaTestServer.test_invocation(sent_event, String) do |received_event, _| + received_event.should eq sent_event + end + + LambdaTestServer.test_invocation(sent_event, Bytes) do |received_event, _| + received_event.should eq sent_event.to_slice + end + + LambdaTestServer.test_invocation(sent_event, IO) do |received_event, _| + received_event.gets_to_end.should eq sent_event + end + + LambdaTestServer.test_invocation(sent_event, NamedTuple(result: Int32)) do |received_event, _| + received_event.should eq({result: 10}) + end + end + + it "properly generates the context object" do + LambdaTestServer.test_invocation("", String) do |_, context| + UUID.parse?(context.aws_request_id).should_not be_nil + context.client_context.should be_nil + context.deadline.should eq Time.utc(2023, 11, 14, 22, 13, 20) + context.function_name.should eq "function_name" + context.function_version.should eq "function_version" + context.identity.should be_nil + context.invoked_function_arn.should eq "arn:aws:lambda:region:account-id:function:test-function" + context.log_group_name.should eq "log_group_name" + context.log_stream_name.should eq "log_stream_name" + context.memory_limit_in_mb.should eq 512 + end + end + + it "serializes the handler responses according to their types" do + handler = ->(event : String, context : Crowbar::Context) { "String Response" } + LambdaTestServer.test_invocation("", handler) do |context| + context.request.body.not_nil!.gets_to_end.should eq "String Response" + end + + handler = ->(event : Bytes, context : Crowbar::Context) { Bytes[0, 0, 0, 0] } + LambdaTestServer.test_invocation("", handler) do |context| + context.request.body.not_nil!.getb_to_end.should eq Bytes[0, 0, 0, 0] + end + + handler = ->(event : Bytes, context : Crowbar::Context) { IO::Memory.new "{}" } + LambdaTestServer.test_invocation("", handler) do |context| + context.request.body.not_nil!.gets_to_end.should eq "{}" + end + + handler = ->(event : Bytes, context : Crowbar::Context) { {status_code: 200} } + LambdaTestServer.test_invocation("", handler) do |context| + context.request.body.not_nil!.gets_to_end.should eq %({"status_code":200}) + end + end + + it "handles errors properly by posting to error endpoint" do + event = %({"wrong_key": 10}) + + Crowbar.capture_log_output do + handler = ->(event : String, context : Crowbar::Context) { raise "Error on handler" } + LambdaTestServer.test_invocation(event, handler) do |context| + context.request.path.should end_with "/error" + error = LambdaTestServer::HandlerError.from_json(context.request.body.not_nil!) + error.message.should eq "Error on handler" + error.type.should eq "Exception" + error.stack_trace.first.should match /spec\/crowbar_spec.cr:\d+:\d+ in '->'/ + end + + handler = ->(event : NamedTuple(value: Int32), context : Crowbar::Context) { event[:value] } + LambdaTestServer.test_invocation(event, handler) do |context| + context.request.path.should end_with "/error" + error = LambdaTestServer::HandlerError.from_json(context.request.body.not_nil!) + error.message.should eq "Missing json attribute: value at line 1, column 1" + error.type.should eq "JSON::ParseException" + error.stack_trace.first.should match /\/usr\/lib\/crystal\/json\/from_json.cr:\d+:\d+ in 'new'/ + end + end + end + end + + describe "handle_events streaming overload" do + it "deserializes the events according to the event type" do + sent_event = %({"result": 10}) + + LambdaTestServer.test_invocation(sent_event, String, Crowbar::ResponseIO) do |received_event, _, _| + received_event.should eq sent_event + end + + LambdaTestServer.test_invocation(sent_event, Bytes, Crowbar::ResponseIO) do |received_event, _, _| + received_event.should eq sent_event.to_slice + end + + LambdaTestServer.test_invocation(sent_event, IO, Crowbar::ResponseIO) do |received_event, _, _| + received_event.gets_to_end.should eq sent_event + end + + LambdaTestServer.test_invocation(sent_event, NamedTuple(result: Int32), Crowbar::ResponseIO) do |received_event, _, _| + received_event.should eq({result: 10}) + end + end + + it "properly generates the context object" do + LambdaTestServer.test_invocation("", String, Crowbar::ResponseIO) do |_, context| + UUID.parse?(context.aws_request_id).should_not be_nil + context.client_context.should be_nil + context.deadline.should eq Time.utc(2023, 11, 14, 22, 13, 20) + context.function_name.should eq "function_name" + context.function_version.should eq "function_version" + context.identity.should be_nil + context.invoked_function_arn.should eq "arn:aws:lambda:region:account-id:function:test-function" + context.log_group_name.should eq "log_group_name" + context.log_stream_name.should eq "log_stream_name" + context.memory_limit_in_mb.should eq 512 + end + end + + it "progressively streams the handler response" do + handler = ->(event : String, context : Crowbar::Context, io : Crowbar::ResponseIO) do + io << "First line of content" + io.flush + io << "Second line of content" + end + + LambdaTestServer.test_invocation("", handler) do |context| + context.request.headers.should eq HTTP::Headers{ + "Content-Type" => "application/octet-stream", + "Host" => "127.0.0.1:9876", + "Lambda-Runtime-Function-Response-Mode" => "streaming", + "Trailer" => "Lambda-Runtime-Function-Error-Type, Lambda-Runtime-Function-Error-Body", + "Transfer-Encoding" => "chunked", + } + + raw_body = context.request.body.as(HTTP::ChunkedContent).io + raw_body.gets(chomp: false).should eq "15\r\n" + raw_body.gets(chomp: false).should eq "First line of content\r\n" + raw_body.gets(chomp: false).should eq "16\r\n" + raw_body.gets(chomp: false).should eq "Second line of content\r\n" + raw_body.gets(chomp: false).should eq "0\r\n" + raw_body.gets(chomp: false).should eq "\r\n" + end + end + + it "handles errors before writes to the response io by posting to error endpoint" do + event = %({"wrong_key": 10}) + + Crowbar.capture_log_output do + handler = ->(event : String, context : Crowbar::Context, io : Crowbar::ResponseIO) { raise "Error on handler" } + LambdaTestServer.test_invocation(event, handler) do |context| + context.request.path.should end_with "/error" + error = LambdaTestServer::HandlerError.from_json(context.request.body.not_nil!) + error.message.should eq "Error on handler" + error.type.should eq "Exception" + error.stack_trace.first.should match /spec\/crowbar_spec.cr:\d+:\d+ in '->'/ + end + + handler = ->(event : NamedTuple(value: Int32), context : Crowbar::Context, io : Crowbar::ResponseIO) { event[:value] } + LambdaTestServer.test_invocation(event, handler) do |context| + context.request.path.should end_with "/error" + error = LambdaTestServer::HandlerError.from_json(context.request.body.not_nil!) + error.message.should eq "Missing json attribute: value at line 1, column 1" + error.type.should eq "JSON::ParseException" + error.stack_trace.first.should match /\/usr\/lib\/crystal\/json\/from_json.cr:\d+:\d+ in 'new'/ + end + end + end + + it "handles errors after writes to the response io by sending trailer" do + event = %({"wrong_key": 10}) + + handler = ->(event : String, context : Crowbar::Context, io : Crowbar::ResponseIO) { + io << "Initial write" + io.flush + raise "Error on handler" + } + + Crowbar.capture_log_output do + LambdaTestServer.test_invocation(event, handler) do |context| + context.request.path.should end_with "/response" + raw_body = context.request.body.as(HTTP::ChunkedContent).io + raw_body.gets(chomp: false).should eq "d\r\n" + raw_body.gets(chomp: false).should eq "Initial write\r\n" + raw_body.gets(chomp: false).should eq "0\r\n" + raw_body.gets(chomp: false).should eq "Lambda-Runtime-Function-Error-Type: Exception\r\n" + raw_body.gets(chomp: false).should match /Lambda-Runtime-Function-Error-Body: .+\r\n/ + raw_body.gets(chomp: false).should eq "\r\n" + end + end + end + + it "serializes http metadata for HttpResponseIO" do + handler = ->(event : String, context : Crowbar::Context, io : Crowbar::HttpResponseIO) do + cookies = HTTP::Cookies{"flavor" => "chocolate", "topper" => "cream"} + cookies["topper"].expires = Time.utc(2025, 1, 1, 10, 10, 10) + + io.headers = HTTP::Headers{"Content-Type" => "application/json", "Target" => "Mars"} + io.cookies = cookies + + io << %({"key":) + io.flush + io << %( "value"}) + end + + LambdaTestServer.test_invocation("", handler) do |context| + context.request.headers.should eq HTTP::Headers{ + "Content-Type" => "application/vnd.awslambda.http-integration-response", + "Host" => "127.0.0.1:9876", + "Lambda-Runtime-Function-Response-Mode" => "streaming", + "Trailer" => "Lambda-Runtime-Function-Error-Type, Lambda-Runtime-Function-Error-Body", + "Transfer-Encoding" => "chunked", + } + + raw_body = context.request.body.as(HTTP::ChunkedContent).io + raw_body.gets(chomp: false).should eq "ae\r\n" + raw_body.gets(chomp: false).should eq %({"statusCode":"ok","headers":{"Content-Type":"application/json","Target":"Mars"},"cookies":["flavor=chocolate","topper=cream; expires=Wed, 01 Jan 2025 10:10:10 GMT"]}\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\r\n) + raw_body.gets(chomp: false).should eq "7\r\n" + raw_body.gets(chomp: false).should eq %({"key":\r\n) + raw_body.gets(chomp: false).should eq "9\r\n" + raw_body.gets(chomp: false).should eq %( "value"}\r\n) + raw_body.gets(chomp: false).should eq "0\r\n" + raw_body.gets(chomp: false).should eq "\r\n" + end + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..4249d97 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,125 @@ +require "spec" +require "uuid" +require "http/server" + +ENV["AWS_LAMBDA_FUNCTION_NAME"] = "function_name" +ENV["AWS_LAMBDA_FUNCTION_VERSION"] = "function_version" +ENV["AWS_LAMBDA_FUNCTION_MEMORY_SIZE"] = "512" +ENV["AWS_LAMBDA_LOG_GROUP_NAME"] = "log_group_name" +ENV["AWS_LAMBDA_LOG_STREAM_NAME"] = "log_stream_name" + +require "../src/crowbar" + +# Crystal's HTTP server is too lenient with malformed requests, so chunked content +# tests are performed on the underlying TCP socket. +class HTTP::ChunkedContent + def io + @io + end +end + +module Crowbar + class_getter captured_logs = IO::Memory.new + + def self.capture_log_output + log_output = @@log_output + self.captured_logs.clear + @@log_output = self.captured_logs + yield + ensure + @@log_output = log_output.not_nil! + end +end + +class LambdaTestServer + class HandlerError + include JSON::Serializable + + @[JSON::Field(key: "errorMessage")] + getter message : String + + @[JSON::Field(key: "errorType")] + getter type : String + + @[JSON::Field(key: "stackTrace")] + getter stack_trace : Array(String) + end + + PORT = 9876 + + property expectation_error : Exception? = nil + + def initialize(@event_body : String, &@response_expectation : HTTP::Server::Context ->) + @server = HTTP::Server.new do |context| + case {context.request.path, context.request.method} + when {"/2018-06-01/runtime/invocation/next", "GET"} then handle_invocation_next(context) + when {/^\/2018-06-01\/runtime\/invocation\/([^\/]+)\/([^\/]+)$/, "POST"} then handle_invocation_response(context, $1, $2) + else raise "Invalid request: #{context.request.method} #{context.request.path}" + end + end + @server.bind_tcp("0.0.0.0", PORT) + end + + def handle_invocation_next(context) + @last_invocation_id = request_id = UUID.v4.to_s + + context.response.headers.merge! HTTP::Headers{ + "Lambda-Runtime-Aws-Request-Id" => request_id, + "Lambda-Runtime-Invoked-Function-Arn" => "arn:aws:lambda:region:account-id:function:test-function", + "Lambda-Runtime-Deadline-Ms" => "1700000000000", + "Lambda-Runtime-Cognito-Identity" => "null", + "Lambda-Runtime-Client-Context" => "null", + "Lambda-Runtime-Trace-Id" => "trace-id", + } + context.response.content_type = "application/json" + context.response.status_code = 200 + context.response << @event_body + end + + def handle_invocation_response(context, invocation_id, response_type) + invocation_id.should eq(@last_invocation_id) + context.response.content_type = "application/json" + + # Execute the handler 2 times to ensure runtime handles multiple invocations + # and respond with an unexpected 200 to break out of the runtime loop + context.response.status_code = @last_invocation_id.nil? ? 202 : 200 + context.response << "Test Concluded" unless @last_invocation_id.nil? + + begin + @response_expectation.call(context) + rescue ex + @expectation_error = ex + end + end + + delegate listen, close, to: @server + + def self.test_invocation(event_body : String, test_handler : Proc(T, Crowbar::Context, U) | Proc(T, Crowbar::Context, U, Nil), &response_expectation : HTTP::Server::Context ->) forall T, U + ENV["AWS_LAMBDA_RUNTIME_API"] = "127.0.0.1:#{PORT}" + test_server = self.new(event_body) { |context| response_expectation.call context } + spawn { test_server.listen } + + Crowbar.handle_events with: test_handler + rescue ex + test_server.not_nil!.close + raise ex unless ex.message.try &.matches? /Unexpected response when responding request '[0-9a-f\-]*': Test Concluded/ + ensure + test_server.not_nil!.expectation_error.try { |error| raise error } + end + + def self.test_invocation(event_body : String, event_type : T.class, &test_handler : T, Crowbar::Context -> U) forall T, U + self.test_invocation(event_body, test_handler) do |context| + if context.request.path.ends_with? "/error" + raise HandlerError.from_json(context.request.body.not_nil!).message + end + end + end + + def self.test_invocation(event_body : String, event_type : T.class, response_io_type : U.class, &test_handler : T, Crowbar::Context, U ->) forall T, U + self.test_invocation(event_body, test_handler) do |context| + if context.request.path.ends_with? "/error" + raise HandlerError.from_json(context.request.body.not_nil!).message + end + end + end +end diff --git a/src/context.cr b/src/context.cr new file mode 100644 index 0000000..bbe2664 --- /dev/null +++ b/src/context.cr @@ -0,0 +1,77 @@ +module Crowbar(T) + class Context + class CognitoIdentity + include JSON::Serializable + + @[JSON::Field(key: "cognitoIdentityId")] + getter cognito_identity_id : String + + @[JSON::Field(key: "cognitoIdentityPoolId")] + getter cognito_identity_pool_id : String + end + + class ClientApplication + include JSON::Serializable + + getter installation_id : String + getter app_title : String + getter app_version_name : String + getter app_version_code : String + getter app_package_name : String + end + + class ClientContext + include JSON::Serializable + + getter client : ClientApplication + getter env : Hash(String, String) + getter custom : Hash(String, String) + end + + getter function_name : String + getter function_version : String + getter memory_limit_in_mb : UInt32 + getter log_group_name : String + getter log_stream_name : String + getter aws_request_id : String + getter invoked_function_arn : String + getter deadline : Time + getter identity : CognitoIdentity? + getter client_context : ClientContext? + + private FUNCTION_NAME = ENV["AWS_LAMBDA_FUNCTION_NAME"] + private FUNCTION_VERSION = ENV["AWS_LAMBDA_FUNCTION_VERSION"] + private MEMORY_LIMIT_IN_MB = UInt32.new(ENV["AWS_LAMBDA_FUNCTION_MEMORY_SIZE"]) + private LOG_GROUP_NAME = ENV["AWS_LAMBDA_LOG_GROUP_NAME"] + private LOG_STREAM_NAME = ENV["AWS_LAMBDA_LOG_STREAM_NAME"] + + def initialize( + @function_name, + @function_version, + @memory_limit_in_mb, + @log_group_name, + @log_stream_name, + @aws_request_id, + @invoked_function_arn, + @deadline, + @identity, + @client_context + ) + end + + def self.new(invocation : HTTP::Client::Response) + new( + function_name: FUNCTION_NAME, + function_version: FUNCTION_VERSION, + memory_limit_in_mb: MEMORY_LIMIT_IN_MB, + log_group_name: LOG_GROUP_NAME, + log_stream_name: LOG_STREAM_NAME, + aws_request_id: invocation.headers["Lambda-Runtime-Aws-Request-Id"], + invoked_function_arn: invocation.headers["Lambda-Runtime-Invoked-Function-Arn"], + deadline: Time.unix_ms(Int64.new(invocation.headers["Lambda-Runtime-Deadline-Ms"])), + identity: invocation.headers["Lambda-Runtime-Cognito-Identity"]?.try { |json| CognitoIdentity?.from_json(json) }, + client_context: invocation.headers["Lambda-Runtime-Client-Context"]?.try { |json| ClientContext?.from_json(json) }, + ) + end + end +end diff --git a/src/crowbar.cr b/src/crowbar.cr new file mode 100644 index 0000000..c522495 --- /dev/null +++ b/src/crowbar.cr @@ -0,0 +1,249 @@ +require "http/client" +require "json" +require "base64" +require "./context" + +private macro handle_event(event_type, invocation, context, output_io = nil) +{% if event_type.resolve <= Bytes %} + yield {{invocation}}.body_io.getb_to_end, {{context}} {% if output_io %},{{output_io}}{% end %} +{% elsif event_type.resolve <= String %} + yield {{invocation}}.body_io.gets_to_end, {{context}} {% if output_io %},{{output_io}}{% end %} +{% elsif event_type.resolve <= IO %} + yield {{invocation}}.body_io, {{context}} {% if output_io %},{{output_io}}{% end %} +{% else %} + yield {{event_type}}.from_json({{invocation}}.body_io), {{context}} {% if output_io %},{{output_io}}{% end %} +{% end %} +end + +private macro handle_invocation_body(event_type, invocation) +{% unless event_type.resolve <= Bytes || event_type.resolve <= String %} + {{invocation}}.body_io.skip_to_end +{% end %} +end + +module Crowbar + private alias PrimitiveResponse = IO | Bytes | String | Nil + + private class_getter log_output : IO = STDERR + + def self.handle_events(with handler : Proc(T, Context, U)) forall T, U + handle_events(of_type: T) { |event, context| handler.call(event, context) } + end + + def self.handle_events(of_type event_type : T.class, &handler : T, Context -> U) forall T, U + host, _, port = ENV["AWS_LAMBDA_RUNTIME_API"].rpartition(':') + client = HTTP::Client.new(host, port) + + loop do + client.get("/2018-06-01/runtime/invocation/next") do |invocation| + raise "Failed to retrieve invocation data: #{invocation.body_io.gets_to_end}" if invocation.status_code != 200 + + ENV["_X_AMZN_TRACE_ID"] = invocation.headers["Lambda-Runtime-Trace-Id"]? + context = Context.new(invocation) + + begin + {% begin %} + begin + result = handle_event({{T}}, invocation, context) + ensure + handle_invocation_body({{T}}, invocation) + end + {% end %} + + response = client.post( + "/2018-06-01/runtime/invocation/#{context.aws_request_id}/response", + body: {% if U.resolve <= PrimitiveResponse %} result {% else %} result.to_json {% end %} + ) + rescue ex + ex.inspect_with_backtrace log_output + response = client.post( + "/2018-06-01/runtime/invocation/#{context.aws_request_id}/error", + body: {errorMessage: ex.message, errorType: ex.class.name, stackTrace: ex.backtrace}.to_json + ) + end + + raise "Unexpected response when responding request '#{context.aws_request_id}': #{response.body}" if response.status_code != 202 + end + end + end + + def self.handle_events(with handler : Proc(T, Context, U, Nil)) forall T, U + handle_events(of_type: T, writing_to: U) { |event, context, response_io| handler.call(event, context, response_io) } + end + + def self.handle_events(of_type event_type : T.class, writing_to response_io_type : U.class, &handler : T, Context, U ->) forall T, U + host, _, port = ENV["AWS_LAMBDA_RUNTIME_API"].rpartition(':') + hostname = URI.unwrap_ipv6(host) + client = HTTP::Client.new(host, port) + + loop do + client.get("/2018-06-01/runtime/invocation/next") do |invocation| + raise "Failed to retrieve invocation data: #{invocation.body_io.gets_to_end}" if invocation.status_code != 200 + + ENV["_X_AMZN_TRACE_ID"] = invocation.headers["Lambda-Runtime-Trace-Id"]? + context = Context.new(invocation) + + response_io = U.new(hostname, port, context.aws_request_id) + + {% begin %} + begin + handle_event({{T}}, invocation, context, response_io) + response_io.write_termination + rescue ex + ex.inspect_with_backtrace log_output + response_io.write_exception ex + ensure + handle_invocation_body({{T}}, invocation) + end + {% end %} + + response = response_io.read_response + response_io.close_socket + + raise "Unexpected response when responding request '#{context.aws_request_id}': #{response.body}" if response.status_code != 202 + end + end + end + + private abstract class BaseResponseIO < IO + include IO::Buffered + + private DEFAULT_STREAMING_HEADERS = <<-HEADERS + Transfer-Encoding: chunked\r + Trailer: Lambda-Runtime-Function-Error-Type, Lambda-Runtime-Function-Error-Body\r + Lambda-Runtime-Function-Response-Mode: streaming\r + + HEADERS + + @content_type = "application/octet-stream" + private property? writing_started = false + + protected def initialize(hostname : String, port : String, @aws_request_id : String) + @socket = TCPSocket.new hostname, port + @host_header = "Host: #{hostname}:#{port}\r\n" + @closed = false + end + + private def write_http_start_line(for response_type : String) + @socket << "POST /2018-06-01/runtime/invocation/" << @aws_request_id << "/" << response_type << " HTTP/1.1\r\n" + end + + private def write_exception_as_http_request(ex : Exception) + body = {errorMessage: ex.message, errorType: ex.class.name, stackTrace: ex.backtrace}.to_json + + write_http_start_line for: "error" + @socket << @host_header + @socket << "Content-Type: application/json\r\n" + @socket << "Content-Length: " << body.bytesize << "\r\n\r\n" + + @socket << body + end + + private def write_exception_as_trailer(ex : Exception) + @socket << "0\r\n" + @socket << "Lambda-Runtime-Function-Error-Type: " << ex.class.name << "\r\n" + @socket << "Lambda-Runtime-Function-Error-Body: " + Base64.strict_encode(ex.inspect_with_backtrace, @socket) + @socket << "\r\n\r\n" + end + + protected def write_exception(ex : Exception) + if writing_started? + flush + write_exception_as_trailer ex + else + write_exception_as_http_request ex + end + end + + private def write_prelude + write_http_start_line for: "response" + @socket << @host_header + @writing_started = true + @socket << DEFAULT_STREAMING_HEADERS << "Content-Type: " << @content_type << "\r\n\r\n" + end + + protected def write_termination + write_prelude unless writing_started? + flush + @socket << "0\r\n\r\n" + end + + protected def read_response + HTTP::Client::Response.from_io @socket + end + + protected def close_socket + @socket.close + end + + def closed? + @closed + end + + def unbuffered_flush + @socket.flush + end + + def unbuffered_close + @closed = true + end + + def unbuffered_read(slice : Bytes) + raise "Can't read from response buffer" + end + + def unbuffered_write(slice : Bytes) : Nil + write_prelude unless writing_started? + + slice.size.to_s(@socket, base: 16) + @socket << "\r\n" + @socket.write(slice) + @socket << "\r\n" + @socket.flush + end + + def unbuffered_rewind + @socket.rewind + end + end + + class ResponseIO < BaseResponseIO + property content_type + end + + class HttpResponseIO < BaseResponseIO + include IO::Buffered + + property status_code : HTTP::Status = HTTP::Status::OK + property headers : HTTP::Headers? + property cookies : HTTP::Cookies? + + private PRELUDE_DELIMITER = Bytes.new(size: 8, value: 0) + + @content_type = "application/vnd.awslambda.http-integration-response" + + private def write_prelude + super + + prelude = String.build(capacity: 128) do |str| + status_code = self.status_code + headers = self.headers + cookies = self.cookies + + JSON.build(str) do |json| + json.object do + json.field "statusCode", status_code + json.field "headers" { headers.to_json json } if headers + json.field "cookies" do + json.array { cookies.each { |cookie| json.string cookie.to_set_cookie_header } } + end if cookies + end + end + str.write PRELUDE_DELIMITER + end.to_slice + + unbuffered_write prelude + end + end +end