LFA 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
+
|