LFA 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +14 -0
- data/LFA.gemspec +34 -0
- data/LICENSE +21 -0
- data/README.md +363 -0
- data/Rakefile +12 -0
- data/config.ru +2 -0
- data/config.yaml +79 -0
- data/data.rb +27 -0
- data/lib/LFA/adapter/environment.rb +36 -0
- data/lib/LFA/adapter/executor.rb +49 -0
- data/lib/LFA/adapter/lambda_rack_bridge.rb +236 -0
- data/lib/LFA/adapter.rb +51 -0
- data/lib/LFA/handler/cors.rb +80 -0
- data/lib/LFA/router/config.rb +185 -0
- data/lib/LFA/router.rb +28 -0
- data/lib/LFA/version.rb +5 -0
- data/lib/LFA.rb +12 -0
- data/myfunc.rb +41 -0
- data/sig/LFA.rbs +4 -0
- data/test.rb +11 -0
- metadata +106 -0
@@ -0,0 +1,236 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require 'cgi'
|
5
|
+
require 'stringio'
|
6
|
+
|
7
|
+
module LFA
|
8
|
+
class Adapter
|
9
|
+
module LambdaRackBridge
|
10
|
+
def request_path(env:)
|
11
|
+
env["REQUEST_PATH"]
|
12
|
+
end
|
13
|
+
|
14
|
+
def http_method(env:)
|
15
|
+
env["REQUEST_METHOD"]
|
16
|
+
end
|
17
|
+
|
18
|
+
def lambda_event(env:, path_parameters:)
|
19
|
+
headers, mv_headers = build_headers(env)
|
20
|
+
query_params, mv_query_params = build_query_params(env)
|
21
|
+
{
|
22
|
+
"resource" => env["REQUEST_PATH"],
|
23
|
+
"path" => env["REQUEST_PATH"],
|
24
|
+
"httpMethod" => env["REQUEST_METHOD"],
|
25
|
+
"headers" => headers,
|
26
|
+
"multiValueHeaders" => mv_headers,
|
27
|
+
"queryStringParameters" => query_params,
|
28
|
+
"multiValueQueryStringParameters" => mv_query_params,
|
29
|
+
"pathParameters" => path_parameters, # set when path parameters are supported
|
30
|
+
"stageVariables" => nil, # LFA will not support deployments on stages
|
31
|
+
"requestContext" => {}, # skipped for now because it seems not useful...
|
32
|
+
"body" => env["rack.input"],
|
33
|
+
"isBase64Encoded" => false, # always false about request? true if the request was binary?
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
def lambda_context(function_name:)
|
38
|
+
LambdaContext.new(function_name)
|
39
|
+
end
|
40
|
+
|
41
|
+
def convert_to_rack_response(resp)
|
42
|
+
### Lambda output
|
43
|
+
# {
|
44
|
+
# statusCode: 200,
|
45
|
+
# body: records.to_json,
|
46
|
+
# headers: {"content-type" => "application/json"},
|
47
|
+
# }
|
48
|
+
### Rack response
|
49
|
+
# [200, {"content-type" => "application/json"}, body]
|
50
|
+
body = StringIO.new(resp[:body])
|
51
|
+
[resp[:statusCode], resp[:headers], body]
|
52
|
+
end
|
53
|
+
|
54
|
+
HEADER_ENV_KEYS = ['CONTENT_TYPE', 'CONTENT_LENGTH']
|
55
|
+
|
56
|
+
def build_headers(env)
|
57
|
+
headers = Headers.new
|
58
|
+
multi_value_headers = Headers.new
|
59
|
+
env.keys.each do |env_key_name|
|
60
|
+
next unless env_key_name =~ /^HTTP_/ || HEADER_ENV_KEYS.include?(env_key_name)
|
61
|
+
next if env_key_name == 'HTTP_VERSION'
|
62
|
+
header_name = header_name_from_env_name(env_key_name)
|
63
|
+
value = env[env_key_name]
|
64
|
+
if value.include?(", ")
|
65
|
+
multi_value_headers[header_name] = value.split(", ").select{|s| !s.empty? }.compact
|
66
|
+
headers[header_name] = value.split(", ").select{|s| !s.empty? }.compact.last
|
67
|
+
else
|
68
|
+
multi_value_headers[header_name] = [value]
|
69
|
+
headers[header_name] = value
|
70
|
+
end
|
71
|
+
end
|
72
|
+
return headers, multi_value_headers
|
73
|
+
end
|
74
|
+
|
75
|
+
def build_query_params(env)
|
76
|
+
query = env["QUERY_STRING"]
|
77
|
+
return nil, nil if !query || query.empty?
|
78
|
+
|
79
|
+
multi_value_query_params = CGI.parse(query)
|
80
|
+
query_params = {}
|
81
|
+
multi_value_query_params.each do |key, value|
|
82
|
+
query_params[key] = value.last
|
83
|
+
end
|
84
|
+
return query_params, multi_value_query_params
|
85
|
+
end
|
86
|
+
|
87
|
+
def header_name_from_env_name(name)
|
88
|
+
name.sub('HTTP_', '').gsub('_', '-').downcase
|
89
|
+
end
|
90
|
+
|
91
|
+
class Headers < Hash
|
92
|
+
def [](key)
|
93
|
+
super(key.downcase)
|
94
|
+
end
|
95
|
+
|
96
|
+
def []=(key, value)
|
97
|
+
super(key.downcase, value)
|
98
|
+
end
|
99
|
+
|
100
|
+
def has_key?(key)
|
101
|
+
super(key.downcase)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
class LambdaContext
|
106
|
+
# Lambda Context https://docs.aws.amazon.com/lambda/latest/dg/ruby-context.html
|
107
|
+
# Context methods
|
108
|
+
# # get_remaining_time_in_millis – Returns the number of milliseconds left before the execution times out.
|
109
|
+
# Context properties
|
110
|
+
# # function_name – The name of the Lambda function.
|
111
|
+
# # function_version – The version of the function.
|
112
|
+
# # invoked_function_arn – The Amazon Resource Name (ARN) that's used to invoke the function.
|
113
|
+
# # Indicates if the invoker specified a version number or alias.
|
114
|
+
# # memory_limit_in_mb – The amount of memory that's allocated for the function.
|
115
|
+
# # aws_request_id – The identifier of the invocation request.
|
116
|
+
# # log_group_name – The log group for the function.
|
117
|
+
# # log_stream_name – The log stream for the function instance.
|
118
|
+
# # deadline_ms– The date that the execution times out, in Unix time milliseconds.
|
119
|
+
# # identity – (mobile apps) Information about the Amazon Cognito identity that authorized the request.
|
120
|
+
# # client_context– (mobile apps) Client context that's provided to Lambda by the client application.
|
121
|
+
|
122
|
+
attr_reader :function_name, :function_version, :invoked_function_arn, :memory_limit_in_mb
|
123
|
+
attr_reader :aws_request_id, :log_group_name, :log_stream_name, :deadline_ms, :identity, :client_context
|
124
|
+
|
125
|
+
def initialize(function_name)
|
126
|
+
@function_name = function_name
|
127
|
+
@function_version = 1
|
128
|
+
@invoked_function_arn = "arn:tagomoris:lambda:ap-northeast-999:0000000000000:function:#{function_name}"
|
129
|
+
@memory_limit_in_mb = 1024 # TODO: configurable?
|
130
|
+
@aws_request_id = SecureRandom.uuid
|
131
|
+
@log_group_name = "/tagomoris/lambda/#{function_name}"
|
132
|
+
@log_stream_name = "2022/12/15[$LATEST]0000000000000000000000"
|
133
|
+
@deadline_ms = 3600 * 1000
|
134
|
+
@identity = nil
|
135
|
+
@client_context = nil
|
136
|
+
end
|
137
|
+
|
138
|
+
def get_remaining_time_in_millis
|
139
|
+
3600 * 1000
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Rack ENV
|
144
|
+
# {
|
145
|
+
# "rack.version"=>[1, 6],
|
146
|
+
# "rack.errors"=>obj,#<Rack::Lint::Wrapper::ErrorWrapper:0x0000000107036620 @error=#<IO:<STDERR>>>,
|
147
|
+
# "rack.multithread"=>true,
|
148
|
+
# "rack.multiprocess"=>false,
|
149
|
+
# "rack.run_once"=>false,
|
150
|
+
# "rack.url_scheme"=>"http",
|
151
|
+
# "SCRIPT_NAME"=>"",
|
152
|
+
# "QUERY_STRING"=>"key=value",
|
153
|
+
# "SERVER_SOFTWARE"=>"puma 6.0.0 Sunflower",
|
154
|
+
# "GATEWAY_INTERFACE"=>"CGI/1.2",
|
155
|
+
# "REQUEST_METHOD"=>"GET",
|
156
|
+
# "REQUEST_PATH"=>"/api/language",
|
157
|
+
# "REQUEST_URI"=>"/api/language?key=value",
|
158
|
+
# "SERVER_PROTOCOL"=>"HTTP/1.1",
|
159
|
+
# "HTTP_HOST"=>"127.0.0.1:9292",
|
160
|
+
# "HTTP_USER_AGENT"=>"curl/7.79.1",
|
161
|
+
# "HTTP_ACCEPT"=>"*/*",
|
162
|
+
# "HTTP_X_YAY"=>"one, two", # Multi value headers
|
163
|
+
# "puma.request_body_wait"=>0.010000228881835938,
|
164
|
+
# "SERVER_NAME"=>"127.0.0.1",
|
165
|
+
# "SERVER_PORT"=>"9292",
|
166
|
+
# "PATH_INFO"=>"/api/language",
|
167
|
+
# "REMOTE_ADDR"=>"127.0.0.1",
|
168
|
+
# "HTTP_VERSION"=>"HTTP/1.1",
|
169
|
+
# "puma.socket"=>obj,#<TCPSocket:fd 16, AF_INET, 127.0.0.1, 9292>,
|
170
|
+
# "rack.hijack?"=>true,
|
171
|
+
# "rack.hijack"=>obj,#<Proc:0x0000000107036a30 /versions/3.1.0/lib/ruby/gems/3.1.0/gems/rack-3.0.2/lib/rack/lint.rb:556>,
|
172
|
+
# "rack.input"=>obj,#<Rack::Lint::Wrapper::InputWrapper:0x0000000107036710 @input=#<Puma::NullIO:0x0000000106fb4b48>>,
|
173
|
+
# "rack.after_reply"=>[],
|
174
|
+
# "puma.config"=>obj,
|
175
|
+
# "rack.tempfiles"=>[],
|
176
|
+
# }
|
177
|
+
|
178
|
+
# Lambda Event
|
179
|
+
# {
|
180
|
+
# "resource"=>"/api/raw_data",
|
181
|
+
# "path"=>"/api/raw_data",
|
182
|
+
# "httpMethod"=>"GET",
|
183
|
+
# "headers"=>{
|
184
|
+
# "accept"=>"*/*", "Host"=>"1jlbwglsci.execute-api.ap-northeast-1.amazonaws.com",
|
185
|
+
# "User-Agent"=>"curl/7.77.0", "X-Amzn-Trace-Id"=>"Root=1-61d5681d-2f0156ce0aeee3896ab2cf1f",
|
186
|
+
# "X-Forwarded-For"=>"138.64.70.55", "X-Forwarded-Port"=>"443", "X-Forwarded-Proto"=>"https",
|
187
|
+
# "x-pathtraq-apikey"=>"711e31a7-5248-45bc-92b8-c39740842a5f"
|
188
|
+
# },
|
189
|
+
# "multiValueHeaders"=>{
|
190
|
+
# "accept"=>["*/*"], "Host"=>["1jlbwglsci.execute-api.ap-northeast-1.amazonaws.com"],
|
191
|
+
# "User-Agent"=>["curl/7.77.0"], "X-Amzn-Trace-Id"=>["Root=1-61d5681d-2f0156ce0aeee3896ab2cf1f"],
|
192
|
+
# "X-Forwarded-For"=>["138.64.70.55"], "X-Forwarded-Port"=>["443"], "X-Forwarded-Proto"=>["https"],
|
193
|
+
# "x-pathtraq-apikey"=>["711e31a7-5248-45bc-92b8-c39740842a5f"]
|
194
|
+
# },
|
195
|
+
# "queryStringParameters"=>nil,
|
196
|
+
# "multiValueQueryStringParameters"=>nil,
|
197
|
+
# "pathParameters"=>nil,
|
198
|
+
# "stageVariables"=>nil,
|
199
|
+
# "requestContext"=>{
|
200
|
+
# "resourceId"=>"541ciq", "resourcePath"=>"/api/raw_data",
|
201
|
+
# "httpMethod"=>"GET", "extendedRequestId"=>"Ld00sEvoNjMF8Zg=",
|
202
|
+
# "requestTime"=>"05/Jan/2022:09:42:53 +0000",
|
203
|
+
# "path"=>"/test/api/raw_data", "accountId"=>"752037627773",
|
204
|
+
# "protocol"=>"HTTP/1.1", "stage"=>"test", "domainPrefix"=>"1jlbwglsci",
|
205
|
+
# "requestTimeEpoch"=>1641375773896, "requestId"=>"32d4b1da-ba45-4758-a2db-467194ffca11",
|
206
|
+
# "identity"=>{
|
207
|
+
# "cognitoIdentityPoolId"=>nil, "accountId"=>nil, "cognitoIdentityId"=>nil, "caller"=>nil,
|
208
|
+
# "sourceIp"=>"138.64.70.55", "principalOrgId"=>nil, "accessKey"=>nil,
|
209
|
+
# "cognitoAuthenticationType"=>nil, "cognitoAuthenticationProvider"=>nil, "userArn"=>nil,
|
210
|
+
# "userAgent"=>"curl/7.77.0", "user"=>nil
|
211
|
+
# },
|
212
|
+
# "domainName"=>"1jlbwglsci.execute-api.ap-northeast-1.amazonaws.com",
|
213
|
+
# "apiId"=>"1jlbwglsci"
|
214
|
+
# },
|
215
|
+
# "body"=>nil,
|
216
|
+
# "isBase64Encoded"=>false
|
217
|
+
# }
|
218
|
+
|
219
|
+
# Lambda Context https://docs.aws.amazon.com/lambda/latest/dg/ruby-context.html
|
220
|
+
# Context methods
|
221
|
+
# # get_remaining_time_in_millis – Returns the number of milliseconds left before the execution times out.
|
222
|
+
# Context properties
|
223
|
+
# # function_name – The name of the Lambda function.
|
224
|
+
# # function_version – The version of the function.
|
225
|
+
# # invoked_function_arn – The Amazon Resource Name (ARN) that's used to invoke the function.
|
226
|
+
# # Indicates if the invoker specified a version number or alias.
|
227
|
+
# # memory_limit_in_mb – The amount of memory that's allocated for the function.
|
228
|
+
# # aws_request_id – The identifier of the invocation request.
|
229
|
+
# # log_group_name – The log group for the function.
|
230
|
+
# # log_stream_name – The log stream for the function instance.
|
231
|
+
# # deadline_ms– The date that the execution times out, in Unix time milliseconds.
|
232
|
+
# # identity – (mobile apps) Information about the Amazon Cognito identity that authorized the request.
|
233
|
+
# # client_context– (mobile apps) Client context that's provided to Lambda by the client application.
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|
data/lib/LFA/adapter.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'adapter/executor'
|
4
|
+
require_relative 'adapter/lambda_rack_bridge'
|
5
|
+
require_relative 'adapter/environment'
|
6
|
+
|
7
|
+
__original_warning_level = $VERBOSE
|
8
|
+
begin
|
9
|
+
$VERBOSE = nil
|
10
|
+
::ENV = LFA::Adapter::EnvMimic.new
|
11
|
+
ensure
|
12
|
+
$VERBOSE = __original_warning_level
|
13
|
+
end
|
14
|
+
|
15
|
+
module LFA
|
16
|
+
class Adapter
|
17
|
+
def initialize(resolver)
|
18
|
+
@resolver = resolver
|
19
|
+
@executors = {}
|
20
|
+
end
|
21
|
+
|
22
|
+
include LambdaRackBridge
|
23
|
+
|
24
|
+
def call(env)
|
25
|
+
begin
|
26
|
+
path = request_path(env: env)
|
27
|
+
method = http_method(env: env)
|
28
|
+
|
29
|
+
matched = @resolver.resolve(path, method)
|
30
|
+
unless matched
|
31
|
+
return [404, {}, ["Resource not found"]]
|
32
|
+
end
|
33
|
+
function = matched.function
|
34
|
+
executor = @executors.fetch(function.name){ Executor.setup(function) }
|
35
|
+
unless @executors.has_key?(function.name)
|
36
|
+
@executors[function.name] = executor
|
37
|
+
end
|
38
|
+
|
39
|
+
event = lambda_event(env: env, path_parameters: matched.path_parameters.dup)
|
40
|
+
context = lambda_context(function_name: function.name)
|
41
|
+
|
42
|
+
result = executor.call(event: event, context: context)
|
43
|
+
return convert_to_rack_response(result) # => [200, {}, ["OK"]]
|
44
|
+
rescue => e
|
45
|
+
p(here: "unexpected error", error_class: e.class, error: e.message)
|
46
|
+
puts e.backtrace
|
47
|
+
return [500, {}, ["Internal Server Error"]]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LFA
|
4
|
+
module Handler
|
5
|
+
module Parameterized
|
6
|
+
def stringify(value)
|
7
|
+
case value
|
8
|
+
when Array
|
9
|
+
value.map(&:to_s).join(', ')
|
10
|
+
when String
|
11
|
+
value
|
12
|
+
when nil
|
13
|
+
nil
|
14
|
+
else
|
15
|
+
value.to_s
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class CORSPreflight
|
21
|
+
include Parameterized
|
22
|
+
|
23
|
+
def initialize(params)
|
24
|
+
@allow_origins = stringify(params[:allowOrigins])
|
25
|
+
@mirror_allow_origin = params[:mirrorAllowOrigin]
|
26
|
+
|
27
|
+
@allow_credentials = stringify(params[:allowCredentials])
|
28
|
+
@allow_headers = stringify(params[:allowHeaders])
|
29
|
+
@allow_methods = stringify(params[:allowMethods])
|
30
|
+
@expose_headers = stringify(params[:exposeHeaders])
|
31
|
+
@max_age = stringify(params[:maxAge])
|
32
|
+
|
33
|
+
if @allow_origins && @mirror_allow_origin
|
34
|
+
raise "Configuration error, allowOrigins and mirrorAllowOrigin are exclusive"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def call(event:, context:)
|
39
|
+
unless event.fetch("httpMethod") == 'OPTIONS'
|
40
|
+
raise "CORS handler can respond to OPTIONS only, but the request is '#{event.fetch("httpMethod")}'"
|
41
|
+
end
|
42
|
+
|
43
|
+
# This handler ignores the preflight request headers below:
|
44
|
+
# * Access-Control-Request-Method
|
45
|
+
# * Access-Control-Request-Headers
|
46
|
+
# System administrator should be able to configure this handler without use of those headers, probably.
|
47
|
+
origin = event.dig("headers", "origin")
|
48
|
+
{statusCode: 200, body: '', headers: cors_headers(origin)}
|
49
|
+
end
|
50
|
+
|
51
|
+
def cors_headers(origin)
|
52
|
+
headers = {}
|
53
|
+
if @allow_origins
|
54
|
+
headers['access-control-allow-origin'] = @allow_origins
|
55
|
+
end
|
56
|
+
if @mirror_allow_origin
|
57
|
+
headers['access-control-allow-origin'] = origin
|
58
|
+
headers['vary'] = 'Origin'
|
59
|
+
end
|
60
|
+
if @allow_credentials
|
61
|
+
headers['access-control-allow-credentials'] = @allow_credentials
|
62
|
+
end
|
63
|
+
if @allow_headers
|
64
|
+
headers['access-control-allow-headers'] = @allow_headers
|
65
|
+
end
|
66
|
+
if @allow_methods
|
67
|
+
headers['access-control-allow-methods'] = @allow_methods
|
68
|
+
end
|
69
|
+
if @expose_headers
|
70
|
+
headers['access-control-expose-headers'] = @expose_headers
|
71
|
+
end
|
72
|
+
if @max_age
|
73
|
+
headers['access-control-max-age'] = @max_age
|
74
|
+
end
|
75
|
+
|
76
|
+
headers
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,185 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'yaml'
|
4
|
+
|
5
|
+
module LFA
|
6
|
+
module Router
|
7
|
+
RESOURCE_METHODS = [
|
8
|
+
:GET, :POST, :PUT, :OPTIONS, :ANY,
|
9
|
+
].freeze
|
10
|
+
|
11
|
+
class Config
|
12
|
+
attr_reader :resources, :functions
|
13
|
+
|
14
|
+
def self.parse(yaml_filename)
|
15
|
+
tree = File.open(yaml_filename) do |file|
|
16
|
+
YAML.load(file.read, symbolize_names: true, aliases: true)
|
17
|
+
end
|
18
|
+
dirname = File.dirname(File.absolute_path(yaml_filename))
|
19
|
+
Config.new(tree, dirname)
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(tree, dirname)
|
23
|
+
# TODO: path-method-to-function cache
|
24
|
+
raise "functions is not specified" unless tree[:functions]
|
25
|
+
raise "resources is not specified" unless tree[:resources]
|
26
|
+
@functions = Hash[*(tree[:functions].map{|f| Function.new(f, dirname) }.map{|f| [f.name, f] }.flatten)]
|
27
|
+
@resources = tree[:resources].map{|r| Resource.new(r, @functions) }
|
28
|
+
end
|
29
|
+
|
30
|
+
def dig(path, method)
|
31
|
+
raise "invalid path" unless path.start_with?("/")
|
32
|
+
parts = if path == "/"
|
33
|
+
["/"]
|
34
|
+
else
|
35
|
+
path.split("/").filter{|str| str.size > 0 }.map{|part| "/" + part}
|
36
|
+
end
|
37
|
+
resource = self
|
38
|
+
path_parameters = {}
|
39
|
+
parts.each_with_index do |part, index|
|
40
|
+
resource = resource.resources.find{|r| r.is_wildcard? || r.path == part }
|
41
|
+
if resource
|
42
|
+
if resource.is_wildcard? && resource.is_greedy_wildcard?
|
43
|
+
path_parameters[resource.parameter_name] = (parts[index..-1].join)[1..-1] # omit heading '/'
|
44
|
+
break
|
45
|
+
elsif resource.is_wildcard?
|
46
|
+
path_parameters[resource.parameter_name] = part[1..-1] # omit heading '/'
|
47
|
+
end
|
48
|
+
else # resource == nil
|
49
|
+
break
|
50
|
+
end
|
51
|
+
end
|
52
|
+
if resource
|
53
|
+
if resource.is_greedy_wildcard?
|
54
|
+
MatchedFunction.new(
|
55
|
+
function: resource.methods.fetch(:ANY),
|
56
|
+
path_parameters: path_parameters,
|
57
|
+
)
|
58
|
+
else
|
59
|
+
func = resource.methods[method.to_sym]
|
60
|
+
if func
|
61
|
+
MatchedFunction.new(
|
62
|
+
function: func,
|
63
|
+
path_parameters: path_parameters.size > 0 ? path_parameters : nil,
|
64
|
+
)
|
65
|
+
else
|
66
|
+
nil
|
67
|
+
end
|
68
|
+
end
|
69
|
+
else
|
70
|
+
nil # when the resource is not found
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
MatchedFunction = Data.define(:function, :path_parameters)
|
76
|
+
|
77
|
+
class Resource
|
78
|
+
attr_reader :path, :parameter_name, :methods, :resources
|
79
|
+
|
80
|
+
def initialize(obj, functions)
|
81
|
+
@path = obj[:path]
|
82
|
+
raise "path must start with '/'" unless @path.start_with?('/')
|
83
|
+
@methods_hash = obj[:methods] || []
|
84
|
+
@resources_array = obj[:resources] || []
|
85
|
+
@methods = {}
|
86
|
+
@methods_hash.each do |method_name, function_name|
|
87
|
+
raise "unsupported method '#{method_name}'" unless RESOURCE_METHODS.include?(method_name)
|
88
|
+
raise "function name missing '#{function_name}'" unless functions.has_key?(function_name)
|
89
|
+
raise "duplicated config on method '#{method_name}'" if @methods[method_name]
|
90
|
+
@methods[method_name] = functions[function_name]
|
91
|
+
end
|
92
|
+
@resources = @resources_array.map{|resource_obj| Resource.new(resource_obj, functions) }
|
93
|
+
|
94
|
+
@parameter_name = nil
|
95
|
+
@greedy_match = nil
|
96
|
+
if @path.start_with?('/{') && @path.end_with?('}')
|
97
|
+
if @path.end_with?('+}')
|
98
|
+
@parameter_name = @path[2..-3]
|
99
|
+
@greedy_match = true
|
100
|
+
else
|
101
|
+
@parameter_name = @path[2..-2]
|
102
|
+
@greedy_match = false
|
103
|
+
end
|
104
|
+
end
|
105
|
+
if @greedy_match.!.! && @methods.keys != [:ANY]
|
106
|
+
raise "resource with a greedy path parameter '{part+}' must respond to only ANY method"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def is_wildcard?
|
111
|
+
@parameter_name != nil
|
112
|
+
end
|
113
|
+
|
114
|
+
def is_greedy_wildcard?
|
115
|
+
@greedy_match.!.!
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
class Function
|
120
|
+
attr_reader :name, :handler, :env, :params, :dirname
|
121
|
+
|
122
|
+
def initialize(obj, dirname)
|
123
|
+
raise "function name, handler are mandatory" unless obj[:name] && obj[:handler]
|
124
|
+
@name = obj[:name]
|
125
|
+
@handler_name = obj[:handler]
|
126
|
+
@env = obj[:env] || {}
|
127
|
+
@params = obj[:params] || {}
|
128
|
+
@handler = if @handler_name =~ /\A[A-Z0-9_]+\z/
|
129
|
+
BuiltInHandler.new(@handler_name)
|
130
|
+
else
|
131
|
+
Handler.parse(@handler_name, dirname)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def inspect
|
136
|
+
"<Function name: #{@name}, handler: #{@handler}, env: #{@env}, params: #{@params}>"
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
class BuiltInHandler
|
141
|
+
attr_reader :name
|
142
|
+
|
143
|
+
def initialize(handler_name)
|
144
|
+
@name = handler_name
|
145
|
+
end
|
146
|
+
|
147
|
+
def builtin?
|
148
|
+
true
|
149
|
+
end
|
150
|
+
|
151
|
+
def inspect
|
152
|
+
"<BuiltInHandler #{@name}>"
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
class Handler
|
157
|
+
attr_reader :filename, :klass, :method
|
158
|
+
|
159
|
+
def initialize(filename, klass, method, dirname)
|
160
|
+
@filename = filename
|
161
|
+
@klass = klass
|
162
|
+
@method = method
|
163
|
+
@dirname = dirname
|
164
|
+
end
|
165
|
+
|
166
|
+
def self.parse(name, dirname)
|
167
|
+
filename, klass, method = name.split('.', 3)
|
168
|
+
raise "invalid handler format '#{name}'" unless filename && klass && method
|
169
|
+
Handler.new(filename, klass, method, dirname)
|
170
|
+
end
|
171
|
+
|
172
|
+
def builtin?
|
173
|
+
false
|
174
|
+
end
|
175
|
+
|
176
|
+
def inspect
|
177
|
+
"<Handler #{@filename}.#{@klass}.#{@method}>"
|
178
|
+
end
|
179
|
+
|
180
|
+
def path
|
181
|
+
File.join(@dirname, @filename + '.rb')
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
data/lib/LFA/router.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative './router/config'
|
4
|
+
|
5
|
+
module LFA
|
6
|
+
module Router
|
7
|
+
def self.resolver(config_filename)
|
8
|
+
config = Config.parse(config_filename)
|
9
|
+
return Resolver.new(config)
|
10
|
+
end
|
11
|
+
|
12
|
+
class Resolver
|
13
|
+
def initialize(config)
|
14
|
+
@config = config
|
15
|
+
@cache = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
def resolve(path, method)
|
19
|
+
cache_key = "#{method}\t#{path}"
|
20
|
+
return @cache[cache_key] if @cache.has_key?(cache_key) # return stored nil when the cache key exists
|
21
|
+
|
22
|
+
matched = @config.dig(path, method)
|
23
|
+
@cache[cache_key] = matched # store negative cache to not pay too much cost for 404 even when nil (404)
|
24
|
+
matched
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/LFA/version.rb
ADDED
data/lib/LFA.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "LFA/version"
|
4
|
+
require_relative "LFA/router"
|
5
|
+
require_relative "LFA/adapter"
|
6
|
+
|
7
|
+
module LFA
|
8
|
+
def self.ignition!(config_filename)
|
9
|
+
router = Router.resolver(config_filename)
|
10
|
+
return Adapter.new(router)
|
11
|
+
end
|
12
|
+
end
|
data/myfunc.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module Countries
|
4
|
+
MESSAGE = ENV.fetch("KEY2", "yay")
|
5
|
+
|
6
|
+
def self.process(event:, context:)
|
7
|
+
data = ENV.fetch("KEY1", "y")
|
8
|
+
{
|
9
|
+
statusCode: 200,
|
10
|
+
body: {"data" => data, "message" => MESSAGE}.to_json,
|
11
|
+
headers: {"content-type" => "application/json"},
|
12
|
+
}
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
require_relative 'data'
|
17
|
+
|
18
|
+
module City
|
19
|
+
# "resource"=>"/api/place/{place_id}", "path"=>"/api/place/tokyo", "pathParameters"=>{"place_id"=>"tokyo"}
|
20
|
+
def self.process(event:, context:)
|
21
|
+
city_name = event.dig("pathParameters", "place_id")
|
22
|
+
{
|
23
|
+
statusCode: 200,
|
24
|
+
body: {"city_name" => city_name, "message" => "good city!"}.to_json,
|
25
|
+
headers: {"content-type" => "application/json"},
|
26
|
+
}
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
module Town
|
31
|
+
# "resource"=>"/api/town/{names+}", "path"=>"/api/checkin/yay1", "pathParameters"=>{"names"=>"yay1"}
|
32
|
+
# "resource"=>"/api/town/{names+}", "path"=>"/api/checkin/yay1/foo2/bar3", "pathParameters"=>{"names"=>"yay1/foo2/bar3"},
|
33
|
+
def self.process(event:, context:)
|
34
|
+
towns = event.dig("pathParameters", "names").split("/")
|
35
|
+
{
|
36
|
+
statusCode: 200,
|
37
|
+
body: {"towns" => towns, "message" => "good towns!"}.to_json,
|
38
|
+
headers: {"content-type" => "application/json"},
|
39
|
+
}
|
40
|
+
end
|
41
|
+
end
|
data/sig/LFA.rbs
ADDED
data/test.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/LFA'
|
4
|
+
|
5
|
+
config = LFA::Router::Config.parse('config.yaml')
|
6
|
+
pp(here: "GET /api/country", function: config.dig("/api/country", "GET"))
|
7
|
+
pp(here: "GET /api/language", function: config.dig("/api/language", "GET"))
|
8
|
+
pp(here: "PUT /api/language", function: config.dig("/api/language", "PUT"))
|
9
|
+
pp(here: "GET /api/data/csv", function: config.dig("/api/data/csv", "GET"))
|
10
|
+
pp(here: "GET /api/data/json", function: config.dig("/api/data/json", "GET"))
|
11
|
+
|