diff --git a/README.md b/README.md index 0fd94fb..49192ed 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,17 @@ You can include this as a dependency in your project in `shards.yml` file ``` dependencies: - lambda_builder: + lambda: github: spinscale/crystal-aws-lambda branch: master ``` Now run the the `shards` command to download the dependency. You can now create your own lambda handlers like this - ```crystal -require "lambda_builder" +require "lambda" -runtime = Lambda::Builder::Runtime.new +runtime = Lambda::Runtime.new runtime.register_handler("httpevent") do |input| req = Lambda::Builder::HTTPRequest.new(input) @@ -131,7 +130,6 @@ sls deploy This will start a sample runtime, that includes a HTTP endpoint, a scheduled event and an SQS listening event. - ## Contributing 1. Fork it () diff --git a/example/shard.yml b/example/shard.yml index c194989..14a543b 100644 --- a/example/shard.yml +++ b/example/shard.yml @@ -5,7 +5,7 @@ authors: - Alexander Reelsen dependencies: - lambda_builder: + lambda: github: spinscale/crystal-aws-lambda branch: master diff --git a/example/src/bootstrap.cr b/example/src/bootstrap.cr index 0e874f8..c42190f 100644 --- a/example/src/bootstrap.cr +++ b/example/src/bootstrap.cr @@ -1,6 +1,6 @@ -require "lambda_builder" +require "lambda" -runtime = Lambda::Builder::Runtime.new +runtime = Lambda::Runtime.new Log.define_formatter( LambdaFormatter, diff --git a/shard.yml b/shard.yml index 1f2fb4c..46284f2 100644 --- a/shard.yml +++ b/shard.yml @@ -1,4 +1,4 @@ -name: lambda_builder +name: lambda version: 0.1.1 authors: diff --git a/spec/lambda_builder/http_request_spec.cr b/spec/lambda/builder/http_request_spec.cr similarity index 98% rename from spec/lambda_builder/http_request_spec.cr rename to spec/lambda/builder/http_request_spec.cr index 48c3a5e..325deea 100644 --- a/spec/lambda_builder/http_request_spec.cr +++ b/spec/lambda/builder/http_request_spec.cr @@ -1,4 +1,4 @@ -require "../spec_helper" +require "../../spec_helper" describe Lambda::Builder::HTTPRequest do it "parses properly" do diff --git a/spec/lambda_builder/http_response_spec.cr b/spec/lambda/builder/http_response_spec.cr similarity index 97% rename from spec/lambda_builder/http_response_spec.cr rename to spec/lambda/builder/http_response_spec.cr index a386dab..054908f 100644 --- a/spec/lambda_builder/http_response_spec.cr +++ b/spec/lambda/builder/http_response_spec.cr @@ -1,4 +1,4 @@ -require "../spec_helper" +require "../../spec_helper" describe Lambda::Builder::HTTPResponse do it "always returns status code" do diff --git a/spec/lambda_builder/runtime_spec.cr b/spec/lambda/runtime_spec.cr similarity index 62% rename from spec/lambda_builder/runtime_spec.cr rename to spec/lambda/runtime_spec.cr index 271b8bb..1837740 100644 --- a/spec/lambda_builder/runtime_spec.cr +++ b/spec/lambda/runtime_spec.cr @@ -6,7 +6,7 @@ def mock_next_invocation(body : String) .to_return(status: 200, body: body, headers: {"Lambda-Runtime-Aws-Request-Id" => "54321", "Lambda-Runtime-Trace-Id" => "TRACE-ID", "Content-Type": "application/json"}) end -describe Lambda::Builder::Runtime do +describe Lambda::Runtime do io = IO::Memory.new Spec.before_each do @@ -18,13 +18,13 @@ describe Lambda::Builder::Runtime do it "can read the runtime API from the environment" do ENV["AWS_LAMBDA_RUNTIME_API"] = "my-host:12345" - runtime = Lambda::Builder::Runtime.new + runtime = Lambda::Runtime.new runtime.host.should eq "my-host" runtime.port.should eq 12345 end it "should be able to register a handler" do - runtime = Lambda::Builder::Runtime.new + runtime = Lambda::Runtime.new # handler = do |_input| JSON.parse Lambda::Builder::HTTPResponse.new(200).to_json end runtime.register_handler("my_handler") do |_input| JSON.parse(%q({ "foo" : "bar"})) @@ -32,7 +32,7 @@ describe Lambda::Builder::Runtime do runtime.handlers["my_handler"].should_not be_nil end - it "can run with an event" do + it "can run with a JSON::Any handler" do body = %Q({ "input" : { "test" : "test" }}) mock_next_invocation body @@ -42,7 +42,7 @@ describe Lambda::Builder::Runtime do HTTP::Client::Response.new(202) end - runtime = Lambda::Builder::Runtime.new + runtime = Lambda::Runtime.new runtime.register_handler("my_handler") do JSON.parse(%q({ "foo" : "bar" })) end @@ -63,7 +63,7 @@ describe Lambda::Builder::Runtime do HTTP::Client::Response.new(202) end - runtime = Lambda::Builder::Runtime.new + runtime = Lambda::Runtime.new runtime.register_handler("my_handler") do response = Lambda::Builder::HTTPResponse.new(200, "text body") response.headers["Content-Type"] = "application/text" @@ -84,7 +84,7 @@ describe Lambda::Builder::Runtime do HTTP::Client::Response.new(202) end - runtime = Lambda::Builder::Runtime.new + runtime = Lambda::Runtime.new runtime.register_handler("my_handler") do raise "anything" end @@ -100,7 +100,7 @@ describe Lambda::Builder::Runtime do HTTP::Client::Response.new(202) end - runtime = Lambda::Builder::Runtime.new + runtime = Lambda::Runtime.new runtime.register_handler("my_handler") do JSON.parse "{}" end @@ -108,4 +108,56 @@ describe Lambda::Builder::Runtime do ENV["_X_AMZN_TRACE_ID"].should eq "TRACE-ID" end + + it "can run with a (String -> String) handler" do + mock_next_invocation request_body_v2 + + WebMock.stub(:post, "http://localhost/2018-06-01/runtime/invocation/54321/response").to_return do |request| + request.body.to_s.should eq "Hi I'm a string" + HTTP::Client::Response.new(202) + end + + runtime = Lambda::Runtime.new + runtime.register_handler("my_handler", String, String) do + "Hi I'm a string" + end + runtime.process_handler + end + + it "can run with a (HTTPRequest -> HTTPResponse) handler" do + mock_next_invocation request_body + + expected = Lambda::Builder::HTTPResponse.new(200, "Hello from Crystal").as_json.to_json + + WebMock.stub(:post, "http://localhost/2018-06-01/runtime/invocation/54321/response").to_return do |request| + request.body.to_s.should eq expected + HTTP::Client::Response.new(202) + end + + runtime = Lambda::Runtime.new + runtime.register_handler("my_handler", Lambda::Builder::HTTPRequest, Lambda::Builder::HTTPResponse) do + Lambda::Builder::HTTPResponse.new(200, "Hello from Crystal") + end + runtime.process_handler + end + + it "can run with a (APIGatewayV2HTTPRequest -> APIGatewayV2HTTPResponse) handler" do + mock_next_invocation request_body_v2 + + expected = Lambda::Events::APIGatewayV2HTTPResponse.new(200, "yolo").to_json + + WebMock.stub(:post, "http://localhost/2018-06-01/runtime/invocation/54321/response").to_return do |request| + request.body.to_s.should eq expected + HTTP::Client::Response.new(202) + end + + runtime = Lambda::Runtime.new + + runtime.register_handler("my_handler", Lambda::Events::APIGatewayV2HTTPRequest, Lambda::Events::APIGatewayV2HTTPResponse) do |input| + response = Lambda::Events::APIGatewayV2HTTPResponse.new(200, "yolo") + response + end + + runtime.process_handler + end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 448c4bb..40bdf6c 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,7 +1,9 @@ require "spec" require "webmock" -require "../src/lambda_builder" +require "../src/lambda" + +Log.setup(:trace) def request_body <<-END @@ -116,3 +118,58 @@ def request_body } END end + +def request_body_v2 + <<-END + { + "version": "2.0", + "routeKey": "OPTIONS /{proxy+}", + "rawPath": "/hi", + "rawQueryString": "", + "headers": { + "accept": "*/*", + "accept-encoding": "gzip, deflate, br", + "accept-language": "en-US,en;q=0.9", + "access-control-request-headers": "authorization,content-type", + "access-control-request-method": "POST", + "content-length": "0", + "content-type": "application/x-www-form-urlencoded", + "forwarded": "by=4.235.36.19;for=108.29.59.253;host=local.dev;proto=https", + "host": "local.dev", + "origin": "https://local.dev", + "referer": "https://local.dev/", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-site", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36", + "via": "HTTP/1.1 AmazonAPIGateway", + "x-amzn-trace-id": "Self=1-60ad1af6-6041ce5d35bd44ad26b42c70;Root=1-60ad1af6-7bbc041f64b16ac44ad97952", + "x-forwarded-for": "3.235.36.19", + "x-forwarded-port": "443", + "x-forwarded-proto": "https" + }, + "pathParameters": { + "proxy": "score" + }, + "requestContext": { + "routeKey": "OPTIONS /{proxy+}", + "accountId": "333957572119", + "stage": "$default", + "requestId": "f5Emhi3LIAMEM7A=", + "apiId": "07lpms4hxh", + "domainName": "local.dev", + "domainPrefix": "local", + "time": "25/May/2021:15:42:46 +0000", + "timeEpoch": 1621957366418, + "http": { + "method": "OPTIONS", + "path": "/hi", + "protocol": "HTTP/1.1", + "sourceIp": "3.235.36.19", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36" + } + }, + "isBase64Encoded": false + } +END +end diff --git a/src/lambda_builder/http_request.cr b/src/lambda/builder/http_request.cr similarity index 100% rename from src/lambda_builder/http_request.cr rename to src/lambda/builder/http_request.cr diff --git a/src/lambda_builder/http_response.cr b/src/lambda/builder/http_response.cr similarity index 100% rename from src/lambda_builder/http_response.cr rename to src/lambda/builder/http_response.cr diff --git a/src/lambda/events/api_gateway_v2_http_request.cr b/src/lambda/events/api_gateway_v2_http_request.cr new file mode 100644 index 0000000..999ac72 --- /dev/null +++ b/src/lambda/events/api_gateway_v2_http_request.cr @@ -0,0 +1,160 @@ +require "json" + +module Lambda::Events + class APIGatewayV2HTTPRequest + include JSON::Serializable + + @[JSON::Field(key: "version")] + property version : String + + @[JSON::Field(key: "routeKey")] + property route_key : String + + @[JSON::Field(key: "rawPath")] + property raw_path : String + + @[JSON::Field(key: "rawQueryString")] + property raw_query_string : String + + @[JSON::Field(key: "cookies")] + property cookies : Array(String)? + + @[JSON::Field(key: "headers")] + property headers : Hash(String, String) + + @[JSON::Field(key: "queryStringParameters")] + property query_string_parameters : Hash(String, String)? + + @[JSON::Field(key: "pathParameters")] + property path_parameters : Hash(String, String)? + + @[JSON::Field(key: "requestContext")] + property request_context : APIGatewayV2HTTPRequestContext? + + @[JSON::Field(key: "stageVariables")] + property stage_variables : Hash(String, String)? + + @[JSON::Field(key: "body")] + property body : String? + + @[JSON::Field(key: "isBase64Encoded")] + property is_base64_encoded : Bool + end + + class APIGatewayV2HTTPRequestContext + include JSON::Serializable + + @[JSON::Field(key: "routeKey")] + property route_key : String + + @[JSON::Field(key: "accountId")] + property account_id : String + + @[JSON::Field(key: "stage")] + property stage : String + + @[JSON::Field(key: "requestId")] + property request_id : String + + @[JSON::Field(key: "authorizer")] + property authorizer : APIGatewayV2HTTPRequestContextAuthorizerDescription? + + @[JSON::Field(key: "apiId")] + property api_id : String + + @[JSON::Field(key: "domainName")] + property domain_name : String + + @[JSON::Field(key: "domainPrefix")] + property domain_prefix : String + + @[JSON::Field(key: "time")] + property time : String + + @[JSON::Field(key: "timeEpoch")] + property time_epoch : Int64 + + @[JSON::Field(key: "http")] + property http : APIGatewayV2HTTPRequestContextHTTPDescription + end + + class APIGatewayV2HTTPRequestContextAuthorizerDescription + include JSON::Serializable + + @[JSON::Field(key: "jwt")] + property jwt : APIGatewayV2HTTPRequestContextAuthorizerJWTDescription? + + @[JSON::Field(key: "lambda")] + property lambda : Hash(String, JSON::Any)? + + @[JSON::Field(key: "iam")] + property iam : APIGatewayV2HTTPRequestContextAuthorizerIAMDescription? + end + + class APIGatewayV2HTTPRequestContextAuthorizerJWTDescription + include JSON::Serializable + + @[JSON::Field(key: "claims")] + property claims : Hash(String, String) + + @[JSON::Field(key: "scopes")] + property scopes : Array(String)? + end + + class APIGatewayV2HTTPRequestContextAuthorizerIAMDescription + include JSON::Serializable + + @[JSON::Field(key: "access_key")] + property access_key : String + + @[JSON::Field(key: "account_id")] + property account_id : String + + @[JSON::Field(key: "callerId")] + property caller_id : String + + @[JSON::Field(key: "cognitoIdentity")] + property cognito_identity : APIGatewayV2HTTPRequestContextAuthorizerCognitoIdentity? + + @[JSON::Field(key: "principalOrgId")] + property principal_org_id : String + + @[JSON::Field(key: "userArn")] + property user_arn : String + + @[JSON::Field(key: "userId")] + property user_id : String + end + + class APIGatewayV2HTTPRequestContextAuthorizerCognitoIdentity + include JSON::Serializable + + @[JSON::Field(key: "amr")] + property amr : Array(String) + + @[JSON::Field(key: "identityId")] + property identity_id : String + + @[JSON::Field(key: "identityPoolId")] + property identity_pool_id : String + end + + class APIGatewayV2HTTPRequestContextHTTPDescription + include JSON::Serializable + + @[JSON::Field(key: "method")] + property method : String + + @[JSON::Field(key: "path")] + property path : String + + @[JSON::Field(key: "protocol")] + property protocol : String + + @[JSON::Field(key: "sourceIp")] + property sourceIp : String + + @[JSON::Field(key: "userAgent")] + property user_agent : String + end +end diff --git a/src/lambda/events/api_gateway_v2_http_response.cr b/src/lambda/events/api_gateway_v2_http_response.cr new file mode 100644 index 0000000..88dcd3c --- /dev/null +++ b/src/lambda/events/api_gateway_v2_http_response.cr @@ -0,0 +1,33 @@ +require "json" + +module Lambda::Events + class APIGatewayV2HTTPResponse + include JSON::Serializable + + @[JSON::Field(key: "statusCode")] + property status_code : Int32 + + @[JSON::Field(key: "headers")] + property headers : Hash(String, String) + + @[JSON::Field(key: "multiValueHeaders")] + property multi_value_headers : Hash(String, Array(String)) + + @[JSON::Field(key: "body")] + property body : String + + @[JSON::Field(key: "isBase64Encoded")] + property is_base64_encoded : Bool? + + @[JSON::Field(key: "cookies")] + property cookies : Array(String) + + def initialize(status_code, body) + @status_code = status_code + @body = body + @headers = Hash(String, String).new + @multi_value_headers = Hash(String, Array(String)).new + @cookies = Array(String).new + end + end +end diff --git a/src/lambda/events/events.cr b/src/lambda/events/events.cr new file mode 100644 index 0000000..938dadf --- /dev/null +++ b/src/lambda/events/events.cr @@ -0,0 +1 @@ +require "./*" diff --git a/src/lambda/lambda.cr b/src/lambda/lambda.cr new file mode 100644 index 0000000..6d5a8ba --- /dev/null +++ b/src/lambda/lambda.cr @@ -0,0 +1,5 @@ +require "./**" + +module Lambda + VERSION = "0.1.1" +end diff --git a/src/lambda/runtime.cr b/src/lambda/runtime.cr new file mode 100644 index 0000000..6bf8842 --- /dev/null +++ b/src/lambda/runtime.cr @@ -0,0 +1,101 @@ +require "http" +require "log" +require "./events" + +module Lambda + alias Handler = (String -> String) | + (JSON::Any -> JSON::Any) | + (Lambda::Builder::HTTPRequest -> Lambda::Builder::HTTPResponse) | + (Lambda::Events::APIGatewayV2HTTPRequest -> Lambda::Events::APIGatewayV2HTTPResponse) + + class Runtime + getter host : String + getter port : Int16 + getter handlers : Hash(String, Handler) = Hash(String, Handler).new + Log = ::Log.for(self) + + def initialize + api = ENV["AWS_LAMBDA_RUNTIME_API"].split(":", 2) + + @host = api[0] + @port = api[1].to_i16 + end + + def register_handler(name : String, input : T.class, output : U.class, &block : T -> U) forall T, U + handler = Proc(T, U).new &block + raise "unsupported handler '#{typeof(handler)}'' must be typeof '#{typeof(Handler)}'" unless handler.is_a?(Handler) + + self.handlers[name] = handler + end + + def register_handler(input : T.class, output : U.class, &block : T -> U) forall T, U + register_handler("default", input, output, &block) + end + + def register_handler(name : String, &block : JSON::Any -> JSON::Any) + register_handler(name, JSON::Any, JSON::Any, &block) + end + + def register_handler(&block : JSON::Any -> JSON::Any) + register_handler("default", JSON::Any, JSON::Any, &block) + end + + def run + loop do + process_handler + end + end + + def process_handler + handler_name = ENV["_HANDLER"] + + if handlers.has_key?(handler_name) + _process_request handlers[handler_name] + else + Log.error { "unknown handler: #{handler_name}, available handlers: #{handlers.keys.join(", ")}" } + end + end + + def execute_handler(handler : Handler, body) + case handler + in Proc(String, String) + handler.call(body) + in Proc(Lambda::Builder::HTTPRequest, Lambda::Builder::HTTPResponse) + req = Lambda::Builder::HTTPRequest.new(JSON.parse(body)) + handler.call(req).as_json.to_json + in Proc(Lambda::Events::APIGatewayV2HTTPRequest, Lambda::Events::APIGatewayV2HTTPResponse) + request = Lambda::Events::APIGatewayV2HTTPRequest.from_json(body) + handler.call(request).to_json + in Proc(JSON::Any, JSON::Any) + handler.call(JSON.parse(body)).to_json + end + end + + def _process_request(handler : Handler) + client = HTTP::Client.new(host: @host, port: @port) + + begin + invocation_response = client.get "/2018-06-01/runtime/invocation/next" + ENV["_X_AMZN_TRACE_ID"] = invocation_response.headers["Lambda-Runtime-Trace-Id"] || "" + + aws_request_id = invocation_response.headers["Lambda-Runtime-Aws-Request-Id"] + invocation_base_url = "/2018-06-01/runtime/invocation/#{aws_request_id}" + + handler_result = execute_handler(handler, invocation_response.body) + + Log.info { "preparing result response #{handler_result}" } + + result_response = client.post("#{invocation_base_url}/response", body: handler_result) + + Log.debug { "result_response #{result_response.status_code} #{result_response.body}" } + rescue ex + body = %Q({ "statusCode": 500, "body" : "#{ex.message}" }) + response = client.post("#{invocation_base_url}/error", body: body) + Log.error { "response error invocation response from exception " \ + "#{ex.message} #{response.status_code} #{response.body}" } + ensure + client.close + end + end + end +end diff --git a/src/lambda_builder.cr b/src/lambda_builder.cr deleted file mode 100644 index 9d0ac00..0000000 --- a/src/lambda_builder.cr +++ /dev/null @@ -1,4 +0,0 @@ -require "./lambda_builder/*" - -module Lambda::Builder -end diff --git a/src/lambda_builder/runtime.cr b/src/lambda_builder/runtime.cr deleted file mode 100644 index 15a0470..0000000 --- a/src/lambda_builder/runtime.cr +++ /dev/null @@ -1,67 +0,0 @@ -require "http" -require "log" -require "./http_request" -require "./http_response" - -module Lambda::Builder - class Runtime - getter host : String - getter port : Int16 - getter handlers : Hash(String, (JSON::Any -> JSON::Any)) = Hash(String, (JSON::Any -> JSON::Any)).new - Log = ::Log.for(self) - - def initialize - api = ENV["AWS_LAMBDA_RUNTIME_API"].split(":", 2) - - @host = api[0] - @port = api[1].to_i16 - end - - # Associate the block/proc to the function name - def register_handler(name : String, &handler : JSON::Any -> JSON::Any) - self.handlers[name] = handler - end - - def run - loop do - process_handler - end - end - - def process_handler - handler_name = ENV["_HANDLER"] - - if handlers.has_key?(handler_name) - _process_request handlers[handler_name] - else - Log.error { "unknown handler: #{handler_name}, available handlers: #{handlers.keys.join(", ")}" } - end - end - - def _process_request(proc : Proc(JSON::Any, JSON::Any)) - client = HTTP::Client.new(host: @host, port: @port) - - begin - response = client.get "/2018-06-01/runtime/invocation/next" - ENV["_X_AMZN_TRACE_ID"] = response.headers["Lambda-Runtime-Trace-Id"] || "" - - aws_request_id = response.headers["Lambda-Runtime-Aws-Request-Id"] - base_url = "/2018-06-01/runtime/invocation/#{aws_request_id}" - - input = JSON.parse response.body - body = proc.call input - - Log.info { "preparing body #{body}" } - response = client.post("#{base_url}/response", body: body.to_json) - Log.debug { "response invocation response #{response.status_code} #{response.body}" } - rescue ex - body = %Q({ "statusCode": 500, "body" : "#{ex.message}" }) - response = client.post("#{base_url}/error", body: body) - Log.error { "response error invocation response from exception " \ - "#{ex.message} #{response.status_code} #{response.body}" } - ensure - client.close - end - end - end -end diff --git a/src/lambda_builder/version.cr b/src/lambda_builder/version.cr deleted file mode 100644 index 20ba6ea..0000000 --- a/src/lambda_builder/version.cr +++ /dev/null @@ -1,3 +0,0 @@ -module Lambda::Builder - VERSION = "0.1.1" -end