functions_framework 0.1.1

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.
@@ -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