LFA 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f5b77411891b6b8397a92d8c12dabb238673b7cd1c0d0b63aa8d7c1028f82d5b
4
+ data.tar.gz: 3d5c792faefa9d0ec321e0b1c18e32c962f1b805cb33e87a50624615f2ff5cfe
5
+ SHA512:
6
+ metadata.gz: 3d9d9dbd849df541ee2f35e6f72d4699f925d657213c874b48405fcf004bdfe3be61899ab094afd4dcea3bc6cb6121aa21f05a74e36a816ed71faaac2456d505
7
+ data.tar.gz: 00e90234cdfadff79c2107e26717e671b39515e7ab301c21cde26e98952a7ada9f7881cdd546e5da9f4e2a75b1e26f7e76d97ec832f3bb74f7de6ca8d04547b9
data/Gemfile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in LFA.gemspec
6
+ gemspec
7
+
8
+ # To launch LFA for testing
9
+ gem "rackup"
10
+ gem "puma"
11
+
12
+ gem "rake", "~> 13.0"
13
+
14
+ gem "test-unit", "~> 3.0"
data/LFA.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/LFA/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "LFA"
7
+ spec.version = LFA::VERSION
8
+ spec.authors = ["Satoshi Tagomori"]
9
+ spec.email = ["tagomoris@gmail.com"]
10
+
11
+ spec.summary = "Lambda Function Adapter for web applications"
12
+ spec.description = "Web application framework to mount AWS Lambda functions as request handlers"
13
+ spec.homepage = "https://github.com/tagomoris/LFA"
14
+ spec.license = "MIT"
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+
18
+ # Specify which files should be added to the gem when it is released.
19
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
+ `git ls-files -z`.split("\x0").reject do |f|
22
+ (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
23
+ end
24
+ end
25
+ spec.bindir = "exe"
26
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
27
+ spec.require_paths = ["lib"]
28
+
29
+ spec.required_ruby_version = ">= 3.2.0"
30
+
31
+ spec.add_dependency "rack"
32
+ spec.add_development_dependency "test-unit"
33
+ spec.add_development_dependency "rake"
34
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Satoshi Tagomori
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,363 @@
1
+ # LFA - Lambda Function Adapter
2
+
3
+ **LFA is under development, and not released yet.**
4
+
5
+ LFA is a web application framework in Ruby (Rack framework), to run AWS Lambda functions (migrated from AWS API Gateway integration) on Ruby's application servers (Unicorn, Puma, etc).
6
+
7
+ The main purposes of LFA are:
8
+
9
+ * run Lambda functions on EC2 (or container hosting services) **temporarily**
10
+ * test Lambda function as web request handler on our laptop
11
+
12
+ LFA was initially designed to host webapps migrated from AWS Lambda to EC2/ECS/k8s/etc by moving functions to app servers as-is. This provides time to engineers for re-implemention of native stand alone web applications.
13
+
14
+ The 2nd purpose (testing on laptop) was found eventually during the development of LFA. We can't test web request handlers of AWS Lambda functions on our laptop directly (because we don't have local API Gateway), but we can do it locally with LFA on laptop. This MAY improve our dev-experience a little, or more.
15
+
16
+ ## Features
17
+
18
+ LFA mounts Lambda functions on request paths, and routes requests to those functions.
19
+
20
+ Lambda functions are:
21
+
22
+ * mounted on specified paths, by AWS Lambda's handler specification `funcfile.Modname.method_name`
23
+ * configured by `ENV` environment variables (just like AWS Lambda)
24
+ * called with `event` and `context` arguments per HTTP request (translated from Rack `env`)
25
+
26
+ LFA uses a YAML file to:
27
+
28
+ * configure functions with those environment variables
29
+ * configure resource-function relations (just like AWS API Gateway)
30
+
31
+ Functions on LFA will be loaded in (semi-)isolated module spaces. Functions will not effect to other functions (at least, unintentionally). See "Limitations" section below about the function isolation.
32
+
33
+ ### Features not supported yet
34
+
35
+ The features below are not supported yet, but will be implemented eventually.
36
+
37
+ * CORS support and built-in `OPTIONS` method request handler
38
+ * Loading functions from `.zip` archives
39
+
40
+ ## How to run LFA
41
+
42
+ ### Installation
43
+
44
+ Add LFA to your application's Gemfile:
45
+
46
+ ```ruby
47
+ gem 'LFA'
48
+ ```
49
+
50
+ And then execute:
51
+
52
+ $ bundle install
53
+
54
+ Or install it yourself as:
55
+
56
+ $ gem install LFA
57
+
58
+ ### Configuration
59
+
60
+ Write a YAML configuration file, and a `config.ru` Rack app file.
61
+
62
+ Rack app file is just to load LFA with the following YAML file.
63
+
64
+ ```ruby
65
+ # config.ru
66
+ require 'LFA'
67
+ run LFA.ignition!('config.yaml')
68
+ ```
69
+
70
+ The YAML configuration file is to describe application resources, and functions to be called per request. The YAML requires 2 child elements:
71
+
72
+ * `resources`: the list of nested resources, almost equal to the resources on AWS API Gateway
73
+ * `functions`: the list of functions, referred from resources
74
+
75
+ ```yaml
76
+ # config.yaml
77
+ ---
78
+ resources:
79
+ - path: /api
80
+ resources:
81
+ - path: /country
82
+ methods:
83
+ GET: myfunc-countries
84
+ - path: /data
85
+ resources:
86
+ - path: /csv
87
+ methods:
88
+ GET: myfunc-data-csv
89
+ - path: /json
90
+ methods:
91
+ GET: myfunc-data-json
92
+ - path: /web
93
+ resources:
94
+ - path: /blog/{entry_id}
95
+ methods:
96
+ GET: blog-entry-get
97
+ - path: /assets/{asset_path+}
98
+ methods:
99
+ ANY: blog-asset-access
100
+ functions:
101
+ - name: myfunc-countries
102
+ handler: myfunc.Countries.process
103
+ env:
104
+ DATABASE_HOSTNAME: mydb.local
105
+ DATABASE_PASSWORD: this-is-the-top-secret
106
+ - name: myfunc-data-csv
107
+ handler: myfunc.Data.process
108
+ env:
109
+ OUTPUT_DATA_TYPE: csv
110
+ - name: myfunc-data-json
111
+ handler: myfunc.Data.process
112
+ env:
113
+ OUTPUT_DATA_TYPE: json
114
+ # omit blog-entry-get and blog-asset-access
115
+ ```
116
+
117
+ The actual Lambda functions should be placed on the same directory with those configuration files.
118
+ LFA will load `Countries` module from `myfunc.rb`, then call its `process` method for the request path `/api/country`.
119
+
120
+ ### Ignition
121
+
122
+ Run your Rack application as usual (for example, with `puma`):
123
+
124
+ $ puma config.ru
125
+
126
+ Or, using `rackup` (requires `gem i rackup`)
127
+
128
+ $ rackup --server puma config.ru
129
+
130
+ ## Limitation
131
+
132
+ The separation of Lambda functions is not perfect. That means:
133
+
134
+ * The Ruby script `funcfile` of Lambda handler `funcfile.Modname.method_name` is loaded in an isolated namespace
135
+ * Libraries `require`-ed from the Lambda file are NOT isolated, and be shared by all Lambda functions in the process
136
+ * `ENV` access out of Lambda handler context in `require`-ed files will see the original `ENV`, instead of `env` configured
137
+
138
+ To avoid those limitations, the Lambda functions loaded by LFA should take care of the following things.
139
+
140
+ ### Use common set of libraries
141
+
142
+ Lambda functions should use the common set of libraries. That means, Lambda functions should:
143
+
144
+ * have a common `lib` directory for its internal libraries
145
+ * have a single `Gemfile` (and `Gemfile.lock`) to use the common set of gems
146
+
147
+ If the Lambda functions are of single application, these things will be satisfied usually. Otherwise, don't share a single LFA process.
148
+
149
+ ### Create handler object/module in function files
150
+
151
+ If your Lambda function's handler module is defined in `funcfile`, it's totally OK.
152
+
153
+ ```ruby
154
+ # func.rb
155
+
156
+ module Modname
157
+ def self.method_name(event:, context:)
158
+ # ...
159
+ end
160
+ end
161
+ # OK
162
+ ```
163
+
164
+ But if the `funcfile` just requires your library and the library defines the handler module or instantiate the handler object, it MAY be NOT OK because that module/object are shared between different functions. It SHOULD be BAD when the handler object has internal states.
165
+
166
+ ```ruby
167
+ # func.rb
168
+ require_relative './lib/myapp/handler'
169
+ # and it provides Modname module
170
+
171
+ # MAY be NOT OK
172
+ ```
173
+
174
+ If your Lambda handler has to have internal states, you should define a class, and instantiate it in `funcfile`, as following:
175
+
176
+ ```ruby
177
+ # lib/myapp/handler.rb
178
+ class MyHandler
179
+ def initialize
180
+ @internal_cache = {}
181
+ end
182
+
183
+ def process(event:, context:)
184
+ # ...
185
+ end
186
+ end
187
+
188
+ # func.rb
189
+ require_relative './lib/myapp/handler'
190
+ Handler = MyHandler.new
191
+
192
+ # and specify the handler: func.Handler.process
193
+ # OK!
194
+ ```
195
+
196
+ ### Refer ENV in dynamic manner
197
+
198
+ LFA overrides the `ENV` reference in `funcfile`.
199
+
200
+ ```ruby
201
+ # func.rb
202
+ module HandlerA
203
+ DB_HOST = ENV['DB_HOST'] # this refers configured `env`
204
+
205
+ def process(event:, context:)
206
+ # ...
207
+ end
208
+ end
209
+ ```
210
+
211
+ But the `ENV` reference in the static context (code not in methods) in required libraries will refer the original environment variables of the LFA process, instead of configured `env` key-value pairs.
212
+
213
+ ```ruby
214
+ # func.rb
215
+ require_relative './lib/myapp/handler'
216
+ Handler = HandlerB.new
217
+
218
+ # lib/myapp/handler.rb
219
+ class HandlerB
220
+ DB_HOST = ENV['DB_HOST'] # this refers the process's environment variables
221
+
222
+ def process(event:, context:)
223
+ # ...
224
+ end
225
+ end
226
+ ```
227
+
228
+ Even in libraries, code in methods called from `funcfile` will refer the configured `env`. So, `ENV` references should be written in methods, called dynamically.
229
+
230
+ ```ruby
231
+ # func.rb
232
+ require_relative './lib/myapp/handler'
233
+ Handler = HandlerB.new
234
+
235
+ # lib/myapp/handler.rb
236
+ class HandlerB
237
+ def initialize
238
+ @db_host = ENV['DB_HOST'] # this refers the `env` configured
239
+ end
240
+
241
+ def process(event:, context:)
242
+ # Or, refer ENV in this handler method
243
+ # ...
244
+ end
245
+ end
246
+ ```
247
+
248
+ Gem libraries referring `ENV` should be initiated in methods, dynamically, as well.
249
+
250
+ ## Predefined Handlers
251
+
252
+ ### CORS: Preflight Request Handler
253
+
254
+ Just like enabling `CORS` on AWS API Gateway, LFA has the built-in CORS preflight request handler. It can respond to `OPTIONS` request on resources, instead of Lambda functions.
255
+
256
+ For the details of CORS requests/responses, see [MDN documents](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) or any other resources.
257
+
258
+ To enable CORS preflight request handler, specify `CORS` as `handler`:
259
+
260
+ ```yaml
261
+ resources:
262
+ - path: /a
263
+ methods:
264
+ GET: func1-myapp1-yay
265
+ OPTIONS: cors-1
266
+ functions:
267
+ - name: cors-1
268
+ handler: CORS
269
+ params:
270
+ allowOrigins:
271
+ - '*'
272
+ allowMethods:
273
+ - GET
274
+ - OPTIONS
275
+ allowHeaders:
276
+ - Content-Type
277
+ - Authorization
278
+ ```
279
+
280
+ All parameters of the handler should be under `params` key. Available parameters are:
281
+
282
+ #### allowOrigins
283
+
284
+ Fixed values for the response header `access-control-allow-origin`. A string, or list of strings.
285
+
286
+ ```yaml
287
+ allowOrigins: '*'
288
+ allowOrigins: 'https://example.com, https://www.example.com'
289
+ allowOrigins:
290
+ - 'https://example.com'
291
+ - 'https://www.example.com'
292
+ ```
293
+
294
+ Exclusive with `mirrorAllowOrigin`. One of `allowOrigins` or `mirrorAllowOrigin` should be specified.
295
+
296
+ #### mirrorAllowOrigin
297
+
298
+ `true` or `false` (or missing) value, which specifies to respond `access-control-allow-origin` value with the value of the request header `Origin`.
299
+
300
+ ```yaml
301
+ mirrorAllowOrigin: true
302
+ ```
303
+
304
+ The handler with `mirrorAllowOrigin: true` will respond `access-control-allow-origin: https://example.com` for the request with a header `origin: https://example.com`.
305
+
306
+ Exclusive with `allowOrigins`. One of `allowOrigins` or `mirrorAllowOrigin` should be specified.
307
+
308
+ #### allowMethods
309
+
310
+ Fixed values for the response header `access-control-allow-methods`. A string, or list of strings. Should be specified.
311
+
312
+ ```yaml
313
+ allowMethods: GET, POST, PUT, DELETE, OPTIONS
314
+ allowMethods:
315
+ - GET
316
+ - POST
317
+ - OPTIONS
318
+ ```
319
+
320
+ #### allowHeaders
321
+
322
+ Fixed values for the response header `access-control-allow-headers`. A string, or list of strings. Optional.
323
+
324
+ ```yaml
325
+ allowHeaders: x-my-custom-header
326
+ allowHeaders:
327
+ - authorization
328
+ - x-my-custom-header
329
+ ```
330
+
331
+ #### allowCredentials
332
+
333
+ `true` or `false` (or missing) to specify `access-control-allow-credentials`. Optional.
334
+
335
+ ```yaml
336
+ allowCredentials: true
337
+ ```
338
+
339
+ #### exposeHeaders
340
+
341
+ Fixed values for the response header `access-control-expose-headers`. A string, or list of strings. Optional.
342
+
343
+ ```yaml
344
+ exposeHeaders: x-my-custom-header
345
+ exposeHeaders:
346
+ - x-my-custom-header
347
+ ```
348
+
349
+ #### maxAge
350
+
351
+ A fixed number (seconds) for the response header `access-control-max-age`. Optional.
352
+
353
+ ```yaml
354
+ maxAge: 86400
355
+ ```
356
+
357
+ ## Contributing
358
+
359
+ Bug reports and pull requests are welcome on GitHub at https://github.com/tagomoris/LFA.
360
+
361
+ ## License
362
+
363
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
data/config.ru ADDED
@@ -0,0 +1,2 @@
1
+ require 'LFA'
2
+ run LFA.ignition!('config.yaml')
data/config.yaml ADDED
@@ -0,0 +1,79 @@
1
+ ---
2
+ resources:
3
+ - path: /api
4
+ resources:
5
+ - path: /country
6
+ methods:
7
+ GET: myfunc-countries
8
+ - path: /language
9
+ methods:
10
+ GET: myfunc2-list-language
11
+ PUT: myfunc2-register-language
12
+ - path: /data
13
+ resources:
14
+ - path: /csv
15
+ methods:
16
+ GET: myfunc-data-csv
17
+ - path: /json
18
+ methods:
19
+ GET: myfunc-data-json
20
+ - path: /city
21
+ resources:
22
+ - path: /{city_name}
23
+ methods:
24
+ GET: myfunc-cities
25
+ - path: /town
26
+ resources:
27
+ - path: /{town_params+}
28
+ methods:
29
+ ANY: myfunc-towns
30
+ - path: /webapi
31
+ methods:
32
+ GET: webapi-func-handler
33
+ POST: webapi-func-handler
34
+ OPTIONS: cors-1
35
+
36
+ functions:
37
+ - name: myfunc-countries
38
+ handler: myfunc.Countries.process
39
+ env:
40
+ KEY1: yay
41
+ KEY2: foooooo
42
+ - name: myfunc2-list-language
43
+ handler: myfunc2.Handler::Language.list
44
+ env:
45
+ KEY1: yayyay
46
+ KEY2: foooooooooo
47
+ - name: myfunc2-register-language
48
+ handler: myfunc2.Handler::Language.register
49
+ env:
50
+ KEY1: yyyyay
51
+ KEY2: foo?
52
+ - name: myfunc-data-csv
53
+ handler: myfunc.Data.process
54
+ env:
55
+ OUTPUT_DATA_TYPE: csv
56
+ - name: myfunc-data-json
57
+ handler: myfunc.Data.process
58
+ env:
59
+ OUTPUT_DATA_TYPE: json
60
+ - name: myfunc-cities
61
+ handler: myfunc.City.process
62
+ - name: myfunc-towns
63
+ handler: myfunc.Town.process
64
+ - name: webapi-func-handler
65
+ handler: webapi.Func.process
66
+ - name: cors-1
67
+ handler: CORS
68
+ params:
69
+ mirrorAllowOrigin: true
70
+ # allowOrigin: '*'
71
+ allowCredentials: true
72
+ allowMethods:
73
+ - GET
74
+ - POST
75
+ - OPTIONS
76
+ allowHeaders:
77
+ - Content-Type
78
+ - Authorization
79
+ maxAge: 3600
data/data.rb ADDED
@@ -0,0 +1,27 @@
1
+ require "json"
2
+
3
+ module Data
4
+ def self.process(event:, context:)
5
+ data_type = ENV.fetch("OUTPUT_DATA_TYPE", "txt")
6
+ case data_type
7
+ when "json"
8
+ {
9
+ statusCode: 200,
10
+ body: {"data" => "boo", "message" => "foo"}.to_json,
11
+ headers: {"content-type" => "application/json"},
12
+ }
13
+ when "csv"
14
+ {
15
+ statusCode: 200,
16
+ body: "data,yaaaay",
17
+ headers: {"content-type" => "text/csv"},
18
+ }
19
+ else
20
+ {
21
+ statusCode: 200,
22
+ body: "data. yaaaaay!",
23
+ headers: {"content-type" => "text/plain"},
24
+ }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module LFA
6
+ class Adapter
7
+ class EnvMimic < Delegator
8
+ def initialize
9
+ @is_active = false
10
+ @box = nil
11
+ @env = ENV
12
+ end
13
+
14
+ def __getobj__
15
+ if @is_active
16
+ @box
17
+ else
18
+ @env
19
+ end
20
+ end
21
+
22
+ def __setobj__(obj)
23
+ @box = obj
24
+ end
25
+
26
+ def mimic!(env)
27
+ @box = env.dup
28
+ @is_active = true
29
+ yield
30
+ ensure
31
+ @box = nil
32
+ @is_active = false
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../handler/cors'
4
+
5
+ module LFA
6
+ class Adapter
7
+ class Executor
8
+ BUILT_IN_HANDLERS = {
9
+ 'CORS' => Handler::CORSPreflight,
10
+ }
11
+
12
+ def self.setup(function)
13
+ if function.handler.builtin?
14
+ BUILT_IN_HANDLERS[function.handler.name].new(function.params)
15
+ else
16
+ env = Hash[*(function.env.map{|k,v| [k.to_s, v] }.flatten)]
17
+ Executor.new(function.name, env, function.handler)
18
+ end
19
+ end
20
+
21
+ def initialize(name, env, handler)
22
+ @name = name
23
+ @env = env
24
+ @klass = handler.klass.to_s
25
+ # @klass must be a string because `const_get(:"A::B")` is not resolved
26
+ # https://bugs.ruby-lang.org/issues/12319
27
+ @method = handler.method.to_sym
28
+
29
+ @enclosure = Module.new
30
+ @enclosure.const_set(:ENV, @env)
31
+ m1 = Module.new
32
+ m1.const_set(:ENV, @env)
33
+ path = handler.path
34
+ ENV.mimic!(@env) do
35
+ load(path, @enclosure)
36
+ end
37
+ @handler_instance = @enclosure.const_get(@klass)
38
+ raise "failed to load the handler module '#{@klass}'" unless @handler_instance
39
+ @handler_method = @handler_instance.method(@method)
40
+ end
41
+
42
+ def call(event:, context:)
43
+ ENV.mimic!(@env) do
44
+ @handler_method.call(event: event, context: context)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end