functions_framework 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,88 @@
1
+ # Copyright 2020 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require "base64"
16
+ require "json"
17
+
18
+ module FunctionsFramework
19
+ module CloudEvents
20
+ ##
21
+ # A content handler for the JSON structure and JSON batch format.
22
+ # See https://github.com/cloudevents/spec/blob/master/json-format.md
23
+ #
24
+ module JsonStructure
25
+ class << self
26
+ ##
27
+ # Decode an event from the given input string
28
+ #
29
+ # @param input [IO] An IO-like object providing a JSON-formatted string
30
+ # @param content_type [FunctionsFramework::CloudEvents::ContentType]
31
+ # the content type
32
+ # @return [FunctionsFramework::CloudEvents::Event]
33
+ #
34
+ def decode_structured_content input, content_type
35
+ input = input.read if input.respond_to? :read
36
+ charset = content_type.charset
37
+ input = input.encode charset if charset
38
+ structure = ::JSON.parse input
39
+ decode_hash_structure structure
40
+ end
41
+
42
+ ##
43
+ # Decode a batch of events from the given input string
44
+ #
45
+ # @param input [IO] An IO-like object providing a JSON-formatted string
46
+ # @param content_type [FunctionsFramework::CloudEvents::ContentType]
47
+ # the content type
48
+ # @return [Array<FunctionsFramework::CloudEvents::Event>]
49
+ #
50
+ def decode_batched_content input, content_type
51
+ input = input.read if input.respond_to? :read
52
+ charset = content_type.charset
53
+ input = input.encode charset if charset
54
+ structure_array = Array(::JSON.parse(input))
55
+ structure_array.map { |structure| decode_hash_structure structure }
56
+ end
57
+
58
+ ##
59
+ # Decode a single event from a hash data structure with keys and types
60
+ # conforming to the JSON event format
61
+ #
62
+ # @param structure [Hash] Input hash
63
+ # @return [FunctionsFramework::CloudEvents::Event]
64
+ #
65
+ def decode_hash_structure structure
66
+ data =
67
+ if structure.key? "data_base64"
68
+ ::Base64.decode64 structure["data_base64"]
69
+ else
70
+ structure["data"]
71
+ end
72
+ spec_version = structure["specversion"]
73
+ raise "Unrecognized specversion: #{spec_version}" unless spec_version == "1.0"
74
+ Event.new \
75
+ id: structure["id"],
76
+ source: structure["source"],
77
+ type: structure["type"],
78
+ spec_version: spec_version,
79
+ data: data,
80
+ data_content_type: structure["datacontenttype"],
81
+ data_schema: structure["dataschema"],
82
+ subject: structure["subject"],
83
+ time: structure["time"]
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,75 @@
1
+ # Copyright 2020 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ module FunctionsFramework
16
+ ##
17
+ # Representation of a function.
18
+ #
19
+ # A function has a name, a type, and a code definition.
20
+ #
21
+ class Function
22
+ ##
23
+ # Create a new function definition.
24
+ #
25
+ # @param name [String] The function name
26
+ # @param type [Symbol] The type of function. Valid types are
27
+ # `:http`, `:event`, and `:cloud_event`.
28
+ # @param block [Proc] The function code as a proc
29
+ #
30
+ def initialize name, type, &block
31
+ @name = name
32
+ @type = type
33
+ @block = block
34
+ end
35
+
36
+ ##
37
+ # @return [String] The function name
38
+ #
39
+ attr_reader :name
40
+
41
+ ##
42
+ # @return [Symbol] The function type
43
+ #
44
+ attr_reader :type
45
+
46
+ ##
47
+ # @return [Proc] The function code as a proc
48
+ #
49
+ attr_reader :block
50
+
51
+ ##
52
+ # Call the function. You must pass an argument appropriate to the type
53
+ # of function.
54
+ #
55
+ # * A `:http` type function takes a `Rack::Request` argument, and returns
56
+ # a Rack response type. See {FunctionsFramework::Registry.add_http}.
57
+ # * A `:event` or `:cloud_event` type function takes a
58
+ # {FunctionsFramework::CloudEvents::Event} argument, and does not
59
+ # return a value. See {FunctionsFramework::Registry.add_cloud_event}.
60
+ # Note that for an `:event` type function, the passed event argument is
61
+ # split into two arguments when passed to the underlying block.
62
+ #
63
+ # @param argument [Rack::Request,FunctionsFramework::CloudEvents::Event]
64
+ # @return [Object]
65
+ #
66
+ def call argument
67
+ case type
68
+ when :event
69
+ block.call argument.data, argument
70
+ else
71
+ block.call argument
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,137 @@
1
+ # Copyright 2020 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require "monitor"
16
+
17
+ module FunctionsFramework
18
+ ##
19
+ # Registry providing lookup of functions by name.
20
+ #
21
+ class Registry
22
+ include ::MonitorMixin
23
+
24
+ ##
25
+ # Create a new empty registry.
26
+ #
27
+ def initialize
28
+ super()
29
+ @functions = {}
30
+ end
31
+
32
+ ##
33
+ # Look up a function definition by name.
34
+ #
35
+ # @param name [String] The function name
36
+ # @return [FunctionsFramework::Function] if the function is found
37
+ # @return [nil] if the function is not found
38
+ #
39
+ def [] name
40
+ @functions[name.to_s]
41
+ end
42
+
43
+ ##
44
+ # Returns the list of defined names
45
+ #
46
+ # @return [Array<String>]
47
+ #
48
+ def names
49
+ @functions.keys.sort
50
+ end
51
+
52
+ ##
53
+ # Add an HTTP function to the registry.
54
+ #
55
+ # You must provide a name for the function, and a block that implemets the
56
+ # function. The block should take a single `Rack::Request` argument. It
57
+ # should return one of the following:
58
+ # * A standard 3-element Rack response array. See
59
+ # https://github.com/rack/rack/blob/master/SPEC
60
+ # * A `Rack::Response` object.
61
+ # * A simple String that will be sent as the response body.
62
+ # * A Hash object that will be encoded as JSON and sent as the response
63
+ # body.
64
+ #
65
+ # @param name [String] The function name
66
+ # @param block [Proc] The function code as a proc
67
+ # @return [self]
68
+ #
69
+ def add_http name, &block
70
+ name = name.to_s
71
+ synchronize do
72
+ raise ::ArgumentError, "Function already defined: #{name}" if @functions.key? name
73
+ @functions[name] = Function.new name, :http, &block
74
+ end
75
+ self
76
+ end
77
+
78
+ ##
79
+ # Add a CloudEvent function to the registry.
80
+ #
81
+ # You must provide a name for the function, and a block that implemets the
82
+ # function. The block should take two arguments: the event _data_ and the
83
+ # event _context_. Any return value is ignored.
84
+ #
85
+ # The event data argument will be one of the following types:
86
+ # * A `String` (with encoding `ASCII-8BIT`) if the data is in the form of
87
+ # binary data. You may choose to perform additional interpretation of
88
+ # the binary data using information in the content type provided by the
89
+ # context argument.
90
+ # * Any data type that can be represented in JSON (i.e. `String`,
91
+ # `Integer`, `Array`, `Hash`, `true`, `false`, or `nil`) if the event
92
+ # came with a JSON payload. The content type may also be set in the
93
+ # context if the data is a String.
94
+ #
95
+ # The context argument will be of type {FunctionsFramework::CloudEvents::Event},
96
+ # and will contain CloudEvents context attributes such as `id` and `type`.
97
+ #
98
+ # See also {#add_cloud_event} which creates a function that takes a single
99
+ # argument of type {FunctionsFramework::CloudEvents::Event}.
100
+ #
101
+ # @param name [String] The function name
102
+ # @param block [Proc] The function code as a proc
103
+ # @return [self]
104
+ #
105
+ def add_event name, &block
106
+ name = name.to_s
107
+ synchronize do
108
+ raise ::ArgumentError, "Function already defined: #{name}" if @functions.key? name
109
+ @functions[name] = Function.new name, :event, &block
110
+ end
111
+ self
112
+ end
113
+
114
+ ##
115
+ # Add a CloudEvent function to the registry.
116
+ #
117
+ # You must provide a name for the function, and a block that implemets the
118
+ # function. The block should take _one_ argument: the event object of type
119
+ # {FunctionsFramework::CloudEvents::Event}. Any return value is ignored.
120
+ #
121
+ # See also {#add_event} which creates a function that takes data and
122
+ # context as separate arguments.
123
+ #
124
+ # @param name [String] The function name
125
+ # @param block [Proc] The function code as a proc
126
+ # @return [self]
127
+ #
128
+ def add_cloud_event name, &block
129
+ name = name.to_s
130
+ synchronize do
131
+ raise ::ArgumentError, "Function already defined: #{name}" if @functions.key? name
132
+ @functions[name] = Function.new name, :cloud_event, &block
133
+ end
134
+ self
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,436 @@
1
+ # Copyright 2020 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require "json"
16
+ require "monitor"
17
+
18
+ require "puma"
19
+ require "puma/server"
20
+ require "rack"
21
+
22
+ module FunctionsFramework
23
+ ##
24
+ # A web server that wraps a function.
25
+ #
26
+ class Server
27
+ include ::MonitorMixin
28
+
29
+ ##
30
+ # Create a new web server given a function. Yields a
31
+ # {FunctionsFramework::Server::Config} object that you can use to set
32
+ # server configuration parameters. This block is the only opportunity to
33
+ # set configuration; once the server is initialized, configuration is
34
+ # frozen.
35
+ #
36
+ # @param function [FunctionsFramework::Function] The function to execute.
37
+ # @yield [FunctionsFramework::Server::Config] A config object that can be
38
+ # manipulated to configure this server.
39
+ #
40
+ def initialize function
41
+ super()
42
+ @config = Config.new
43
+ yield @config if block_given?
44
+ @config.freeze
45
+ @function = function
46
+ @app =
47
+ case function.type
48
+ when :http
49
+ HttpApp.new function, @config
50
+ when :event, :cloud_event
51
+ EventApp.new function, @config
52
+ else
53
+ raise "Unrecognized function type: #{function.type}"
54
+ end
55
+ @server = nil
56
+ @signals_installed = false
57
+ end
58
+
59
+ ##
60
+ # The function to execute.
61
+ # @return [FunctionsFramework::Function]
62
+ #
63
+ attr_reader :function
64
+
65
+ ##
66
+ # The final configuration. This is a frozen object that cannot be modified.
67
+ # @return [FunctionsFramework::Server::Config]
68
+ #
69
+ attr_reader :config
70
+
71
+ ##
72
+ # Start the web server in the background. Does nothing if the web server
73
+ # is already running.
74
+ #
75
+ # @return [self]
76
+ #
77
+ def start
78
+ synchronize do
79
+ unless running?
80
+ @server = ::Puma::Server.new @app
81
+ @server.min_threads = @config.min_threads
82
+ @server.max_threads = @config.max_threads
83
+ @server.leak_stack_on_error = @config.show_error_details?
84
+ @server.binder.add_tcp_listener @config.bind_addr, @config.port
85
+ @server.run true
86
+ @config.logger.info "FunctionsFramework: Serving function #{@function.name.inspect}" \
87
+ " on port #{@config.port}..."
88
+ end
89
+ end
90
+ self
91
+ end
92
+
93
+ ##
94
+ # Stop the web server in the background. Does nothing if the web server
95
+ # is not running.
96
+ #
97
+ # @param force [Boolean] Use a forced halt instead of a graceful shutdown
98
+ # @param wait [Boolean] Block until shutdown is complete
99
+ # @return [self]
100
+ #
101
+ def stop force: false, wait: false
102
+ synchronize do
103
+ if running?
104
+ @config.logger.info "FunctionsFramework: Shutting down server..."
105
+ if force
106
+ @server.halt wait
107
+ else
108
+ @server.stop wait
109
+ end
110
+ end
111
+ end
112
+ self
113
+ end
114
+
115
+ ##
116
+ # Wait for the server to stop. Returns immediately if the server is not
117
+ # running.
118
+ #
119
+ # @param timeout [nil,Numeric] The timeout. If `nil` (the default), waits
120
+ # indefinitely, otherwise times out after the given number of seconds.
121
+ # @return [self]
122
+ #
123
+ def wait_until_stopped timeout: nil
124
+ @server&.thread&.join timeout
125
+ self
126
+ end
127
+
128
+ ##
129
+ # Determine if the web server is currently running
130
+ #
131
+ # @return [Boolean]
132
+ #
133
+ def running?
134
+ @server&.thread&.alive?
135
+ end
136
+
137
+ ##
138
+ # Cause this server to respond to SIGTERM, SIGINT, and SIGHUP by shutting
139
+ # down gracefully.
140
+ #
141
+ # @return [self]
142
+ #
143
+ def respond_to_signals
144
+ synchronize do
145
+ return self if @signals_installed
146
+ ::Signal.trap "SIGTERM" do
147
+ Server.signal_enqueue "SIGTERM", @config.logger, @server
148
+ end
149
+ ::Signal.trap "SIGINT" do
150
+ Server.signal_enqueue "SIGINT", @config.logger, @server
151
+ end
152
+ ::Signal.trap "SIGHUP" do
153
+ Server.signal_enqueue "SIGHUP", @config.logger, @server
154
+ end
155
+ @signals_installed = true
156
+ end
157
+ self
158
+ end
159
+
160
+ class << self
161
+ ## @private
162
+ def start_signal_queue
163
+ @signal_queue = ::Queue.new
164
+ ::Thread.start do
165
+ loop do
166
+ signal, logger, server = @signal_queue.pop
167
+ logger.info "FunctionsFramework: Caught #{signal}; shutting down server..."
168
+ server&.stop
169
+ end
170
+ end
171
+ end
172
+
173
+ ## @private
174
+ def signal_enqueue signal, logger, server
175
+ @signal_queue << [signal, logger, server]
176
+ end
177
+ end
178
+
179
+ start_signal_queue
180
+
181
+ ##
182
+ # The web server configuration. This object is yielded from the
183
+ # {FunctionsFramework::Server} constructor and can be modified at that
184
+ # point. Afterward, it is available from {FunctionsFramework::Server#config}
185
+ # but it is frozen.
186
+ #
187
+ class Config
188
+ ##
189
+ # Create a new config object with the default settings
190
+ #
191
+ def initialize
192
+ self.rack_env = nil
193
+ self.bind_addr = nil
194
+ self.port = nil
195
+ self.min_threads = nil
196
+ self.max_threads = nil
197
+ self.show_error_details = nil
198
+ self.logger = nil
199
+ end
200
+
201
+ ##
202
+ # Set the Rack environment, or `nil` to use the default.
203
+ # @param rack_env [String,nil]
204
+ #
205
+ def rack_env= rack_env
206
+ @rack_env = rack_env || ::ENV["RACK_ENV"] ||
207
+ (::ENV["K_REVISION"] ? "production" : "development")
208
+ end
209
+
210
+ ##
211
+ # Set the bind address, or `nil` to use the default.
212
+ # @param bind_addr [String,nil]
213
+ #
214
+ def bind_addr= bind_addr
215
+ @bind_addr = bind_addr || ::ENV["BIND_ADDR"] || "0.0.0.0"
216
+ end
217
+
218
+ ##
219
+ # Set the port number, or `nil` to use the default.
220
+ # @param port [Integer,nil]
221
+ #
222
+ def port= port
223
+ @port = (port || ::ENV["PORT"] || 8080).to_i
224
+ end
225
+
226
+ ##
227
+ # Set the minimum number of worker threads, or `nil` to use the default.
228
+ # @param min_threads [Integer,nil]
229
+ #
230
+ def min_threads= min_threads
231
+ @min_threads = (min_threads || ::ENV["MIN_THREADS"])&.to_i
232
+ end
233
+
234
+ ##
235
+ # Set the maximum number of worker threads, or `nil` to use the default.
236
+ # @param max_threads [Integer,nil]
237
+ #
238
+ def max_threads= max_threads
239
+ @max_threads = (max_threads || ::ENV["MAX_THREADS"])&.to_i
240
+ end
241
+
242
+ ##
243
+ # Set whether to show detailed error messages, or `nil` to use the default.
244
+ # @param show_error_details [Boolean,nil]
245
+ #
246
+ def show_error_details= show_error_details
247
+ val = show_error_details.nil? ? ::ENV["DETAILED_ERRORS"] : show_error_details
248
+ @show_error_details = val ? true : false
249
+ end
250
+
251
+ ##
252
+ # Set the logger for server messages, or `nil` to use the global default.
253
+ # @param logger [Logger]
254
+ #
255
+ def logger= logger
256
+ @logger = logger || ::FunctionsFramework.logger
257
+ end
258
+
259
+ ##
260
+ # Returns the current Rack environment.
261
+ # @return [String]
262
+ #
263
+ def rack_env
264
+ @rack_env
265
+ end
266
+
267
+ ##
268
+ # Returns the current bind address.
269
+ # @return [String]
270
+ #
271
+ def bind_addr
272
+ @bind_addr
273
+ end
274
+
275
+ ##
276
+ # Returns the current port number.
277
+ # @return [Integer]
278
+ #
279
+ def port
280
+ @port
281
+ end
282
+
283
+ ##
284
+ # Returns the minimum number of worker threads in the thread pool.
285
+ # @return [Integer]
286
+ #
287
+ def min_threads
288
+ @min_threads || 1
289
+ end
290
+
291
+ ##
292
+ # Returns the maximum number of worker threads in the thread pool.
293
+ # @return [Integer]
294
+ #
295
+ def max_threads
296
+ @max_threads || (@rack_env == "development" ? 1 : 16)
297
+ end
298
+
299
+ ##
300
+ # Returns whether to show detailed error messages.
301
+ # @return [Boolean]
302
+ #
303
+ def show_error_details?
304
+ @show_error_details.nil? ? (@rack_env == "development") : @show_error_details
305
+ end
306
+
307
+ ##
308
+ # Returns the logger.
309
+ # @return [Logger]
310
+ #
311
+ def logger
312
+ @logger
313
+ end
314
+ end
315
+
316
+ ## @private
317
+ class AppBase
318
+ BLACKLISTED_PATHS = ["/favicon.ico", "/robots.txt"].freeze
319
+
320
+ def initialize config
321
+ @config = config
322
+ end
323
+
324
+ def blacklisted_path? env
325
+ path = env[::Rack::SCRIPT_NAME].to_s + env[::Rack::PATH_INFO].to_s
326
+ BLACKLISTED_PATHS.include? path
327
+ end
328
+
329
+ def interpret_response response
330
+ case response
331
+ when ::Array
332
+ response
333
+ when ::Rack::Response
334
+ response.finish
335
+ when ::String
336
+ string_response response, "text/plain", 200
337
+ when ::Hash
338
+ json = ::JSON.dump response
339
+ string_response json, "application/json", 200
340
+ when ::StandardError
341
+ error = error_message response
342
+ string_response error, "text/plain", 500
343
+ else
344
+ e = ::StandardError.new "Unexpected response type: #{response.class}"
345
+ error = error_message e
346
+ string_response error, "text/plain", 500
347
+ end
348
+ end
349
+
350
+ def notfound_response
351
+ string_response "Not found", "text/plain", 404
352
+ end
353
+
354
+ def string_response string, content_type, status
355
+ headers = {
356
+ "Content-Type" => content_type,
357
+ "Content-Length" => string.bytesize
358
+ }
359
+ [status, headers, [string]]
360
+ end
361
+
362
+ def error_message error
363
+ if @config.show_error_details?
364
+ "#{error.class}: #{error.message}\n#{error.backtrace}\n"
365
+ else
366
+ "Unexpected internal error"
367
+ end
368
+ end
369
+
370
+ def usage_message error
371
+ if @config.show_error_details?
372
+ "Failed to decode CloudEvent: #{error.inspect}"
373
+ else
374
+ "Failed to decode CloudEvent"
375
+ end
376
+ end
377
+ end
378
+
379
+ ## @private
380
+ class HttpApp < AppBase
381
+ def initialize function, config
382
+ super config
383
+ @function = function
384
+ end
385
+
386
+ def call env
387
+ return notfound_response if blacklisted_path? env
388
+ response =
389
+ begin
390
+ logger = env["rack.logger"] = @config.logger
391
+ request = ::Rack::Request.new env
392
+ logger.info "FunctionsFramework: Handling HTTP #{request.request_method} request"
393
+ @function.call request
394
+ rescue ::StandardError => e
395
+ logger.warn e
396
+ e
397
+ end
398
+ interpret_response response
399
+ end
400
+ end
401
+
402
+ ## @private
403
+ class EventApp < AppBase
404
+ def initialize function, config
405
+ super config
406
+ @function = function
407
+ end
408
+
409
+ def call env
410
+ return notfound_response if blacklisted_path? env
411
+ logger = env["rack.logger"] = @config.logger
412
+ event =
413
+ begin
414
+ CloudEvents.decode_rack_env env
415
+ rescue ::StandardError => e
416
+ e
417
+ end
418
+ response =
419
+ if event.is_a? CloudEvents::Event
420
+ logger.info "FunctionsFramework: Handling CloudEvent"
421
+ begin
422
+ @function.call event
423
+ "ok"
424
+ rescue ::StandardError => e
425
+ logger.warn e
426
+ e
427
+ end
428
+ else
429
+ logger.warn e.inspect
430
+ string_response usage_message(e), "text/plain", 400
431
+ end
432
+ interpret_response response
433
+ end
434
+ end
435
+ end
436
+ end