functions_framework 0.1.1 → 0.4.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.
@@ -16,21 +16,59 @@ module FunctionsFramework
16
16
  ##
17
17
  # Representation of a function.
18
18
  #
19
- # A function has a name, a type, and a code definition.
19
+ # A function has a name, a type, and an implementation.
20
+ #
21
+ # The implementation in general is an object that responds to the `call`
22
+ # method. For a function of type `:http`, the `call` method takes a single
23
+ # `Rack::Request` argument and returns one of various HTTP response types.
24
+ # See {FunctionsFramework::Registry.add_http}. For a function of type
25
+ # `:cloud_event`, the `call` method takes a single
26
+ # {FunctionsFramework::CloudEvents::Event CloudEvent} argument, and does not
27
+ # return a value. See {FunctionsFramework::Registry.add_cloud_event}.
28
+ #
29
+ # If a callable object is provided directly, its `call` method is invoked for
30
+ # every function execution. Note that this means it may be called multiple
31
+ # times concurrently in separate threads.
32
+ #
33
+ # Alternately, the implementation may be provided as a class that should be
34
+ # instantiated to produce a callable object. If a class is provided, it should
35
+ # either subclass {FunctionsFramework::Function::CallBase} or respond to the
36
+ # same constructor interface, i.e. accepting arbitrary keyword arguments. A
37
+ # separate callable object will be instantiated from this class for every
38
+ # function invocation, so each instance will be used for only one invocation.
39
+ #
40
+ # Finally, an implementation can be provided as a block. If a block is
41
+ # provided, it will be recast as a `call` method in an anonymous subclass of
42
+ # {FunctionsFramework::Function::CallBase}. Thus, providing a block is really
43
+ # just syntactic sugar for providing a class. (This means, for example, that
44
+ # the `return` keyword will work within the block because it is treated as a
45
+ # method.)
20
46
  #
21
47
  class Function
22
48
  ##
23
49
  # Create a new function definition.
24
50
  #
25
51
  # @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
52
+ # @param type [Symbol] The type of function. Valid types are `:http` and
53
+ # `:cloud_event`.
54
+ # @param callable [Class,#call] A callable object or class.
55
+ # @param block [Proc] The function code as a block.
29
56
  #
30
- def initialize name, type, &block
57
+ def initialize name, type, callable = nil, &block
31
58
  @name = name
32
59
  @type = type
33
- @block = block
60
+ @callable = @callable_class = nil
61
+ if callable.respond_to? :call
62
+ @callable = callable
63
+ elsif callable.is_a? ::Class
64
+ @callable_class = callable
65
+ elsif block_given?
66
+ @callable_class = ::Class.new CallBase do
67
+ define_method :call, &block
68
+ end
69
+ else
70
+ raise ::ArgumentError, "No callable given for function"
71
+ end
34
72
  end
35
73
 
36
74
  ##
@@ -44,32 +82,48 @@ module FunctionsFramework
44
82
  attr_reader :type
45
83
 
46
84
  ##
47
- # @return [Proc] The function code as a proc
85
+ # Get a callable for performing a function invocation. This will either
86
+ # return the singleton callable object, or instantiate a new callable from
87
+ # the configured class.
48
88
  #
49
- attr_reader :block
89
+ # @param logger [::Logger] The logger for use by function executions. This
90
+ # may or may not be used by the callable.
91
+ # @return [#call]
92
+ #
93
+ def new_call logger: nil
94
+ return @callable unless @callable.nil?
95
+ logger ||= FunctionsFramework.logger
96
+ @callable_class.new logger: logger, function_name: name, function_type: type
97
+ end
50
98
 
51
99
  ##
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.
100
+ # A base class for a callable object that provides calling context.
62
101
  #
63
- # @param argument [Rack::Request,FunctionsFramework::CloudEvents::Event]
64
- # @return [Object]
102
+ # An object of this class is `self` while a function block is running.
65
103
  #
66
- def call argument
67
- case type
68
- when :event
69
- block.call argument.data, argument
70
- else
71
- block.call argument
104
+ class CallBase
105
+ ##
106
+ # Create a callable object with the given context.
107
+ #
108
+ # @param context [keywords] A set of context arguments. See {#context} for
109
+ # a list of keys that will generally be passed in. However,
110
+ # implementations should be prepared to accept any abritrary keys.
111
+ #
112
+ def initialize **context
113
+ @context = context
72
114
  end
115
+
116
+ ##
117
+ # A keyed hash of context information. Common context keys include:
118
+ #
119
+ # * **:logger** (`Logger`) A logger for use by this function call.
120
+ # * **:function_name** (`String`) The name of the running function.
121
+ # * **:function_type** (`Symbol`) The type of the running function,
122
+ # either `:http` or `:cloud_event`.
123
+ #
124
+ # @return [Hash]
125
+ #
126
+ attr_reader :context
73
127
  end
74
128
  end
75
129
  end
@@ -0,0 +1,145 @@
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
+
17
+ module FunctionsFramework
18
+ ##
19
+ # Converter from legacy GCF event formats to CloudEvents.
20
+ #
21
+ class LegacyEventConverter
22
+ ##
23
+ # Decode an event from the given Rack environment hash.
24
+ #
25
+ # @param env [Hash] The Rack environment
26
+ # @return [FunctionsFramework::CloudEvents::Event] if the request could
27
+ # be converted
28
+ # @return [nil] if the event format was not recognized.
29
+ #
30
+ def decode_rack_env env
31
+ content_type = CloudEvents::ContentType.new env["CONTENT_TYPE"]
32
+ return nil unless content_type.media_type == "application" && content_type.subtype_base == "json"
33
+ input = read_input_json env["rack.input"], content_type.charset
34
+ return nil unless input
35
+ raw_context = input["context"] || input
36
+ context = normalized_context raw_context
37
+ return nil unless context
38
+ construct_cloud_event context, input["data"]
39
+ end
40
+
41
+ private
42
+
43
+ def read_input_json input, charset
44
+ input = input.read if input.respond_to? :read
45
+ input = input.encode charset if charset
46
+ content = ::JSON.parse input
47
+ content = nil unless content.is_a? ::Hash
48
+ content
49
+ rescue ::JSON::ParserError
50
+ nil
51
+ end
52
+
53
+ def normalized_context raw_context
54
+ id = raw_context["eventId"]
55
+ return nil unless id
56
+ timestamp = raw_context["timestamp"]
57
+ return nil unless timestamp
58
+ type = raw_context["eventType"]
59
+ return nil unless type
60
+ service, resource = analyze_resource raw_context["resource"], type
61
+ return nil unless service && resource
62
+ { id: id, timestamp: timestamp, type: type, service: service, resource: resource }
63
+ end
64
+
65
+ def analyze_resource raw_resource, type
66
+ case raw_resource
67
+ when ::Hash
68
+ [raw_resource["service"], raw_resource["name"]]
69
+ when ::String
70
+ [service_from_type(type), raw_resource]
71
+ else
72
+ [nil, nil]
73
+ end
74
+ end
75
+
76
+ def service_from_type type
77
+ LEGACY_TYPE_TO_SERVICE.each do |pattern, service|
78
+ return service if pattern =~ type
79
+ end
80
+ nil
81
+ end
82
+
83
+ def construct_cloud_event context, data
84
+ source, subject = convert_source context[:service], context[:resource]
85
+ type = LEGACY_TYPE_TO_CE_TYPE[context[:type]]
86
+ return nil unless type && source
87
+ ce_data = convert_data context[:service], data
88
+ CloudEvents::Event.new id: context[:id],
89
+ source: source,
90
+ type: type,
91
+ spec_version: "1.0",
92
+ data_content_type: "application/json",
93
+ data: ce_data,
94
+ subject: subject,
95
+ time: context[:timestamp]
96
+ end
97
+
98
+ def convert_source service, resource
99
+ if service == "storage.googleapis.com"
100
+ match = %r{^(projects/[^/]+/buckets/[^/]+)/([^#]+)(?:#.*)?$}.match resource
101
+ return [nil, nil] unless match
102
+ ["//#{service}/#{match[1]}", match[2]]
103
+ else
104
+ ["//#{service}/#{resource}", nil]
105
+ end
106
+ end
107
+
108
+ def convert_data service, data
109
+ if service == "pubsub.googleapis.com"
110
+ { "message" => data, "subscription" => nil }
111
+ else
112
+ data
113
+ end
114
+ end
115
+
116
+ LEGACY_TYPE_TO_SERVICE = {
117
+ %r{^providers/cloud\.firestore/} => "firestore.googleapis.com",
118
+ %r{^providers/cloud\.pubsub/} => "pubsub.googleapis.com",
119
+ %r{^providers/cloud\.storage/} => "storage.googleapis.com",
120
+ %r{^providers/firebase\.auth/} => "firebase.googleapis.com",
121
+ %r{^providers/google\.firebase} => "firebase.googleapis.com"
122
+ }.freeze
123
+
124
+ LEGACY_TYPE_TO_CE_TYPE = {
125
+ "google.pubsub.topic.publish" => "google.cloud.pubsub.topic.v1.messagePublished",
126
+ "providers/cloud.pubsub/eventTypes/topic.publish" => "google.cloud.pubsub.topic.v1.messagePublished",
127
+ "google.storage.object.finalize" => "google.cloud.storage.object.v1.finalized",
128
+ "google.storage.object.delete" => "google.cloud.storage.object.v1.deleted",
129
+ "google.storage.object.archive" => "google.cloud.storage.object.v1.archived",
130
+ "google.storage.object.metadataUpdate" => "google.cloud.storage.object.v1.metadataUpdated",
131
+ "providers/cloud.firestore/eventTypes/document.write" => "google.cloud.firestore.document.v1.written",
132
+ "providers/cloud.firestore/eventTypes/document.create" => "google.cloud.firestore.document.v1.created",
133
+ "providers/cloud.firestore/eventTypes/document.update" => "google.cloud.firestore.document.v1.updated",
134
+ "providers/cloud.firestore/eventTypes/document.delete" => "google.cloud.firestore.document.v1.deleted",
135
+ "providers/firebase.auth/eventTypes/user.create" => "google.firebase.auth.user.v1.created",
136
+ "providers/firebase.auth/eventTypes/user.delete" => "google.firebase.auth.user.v1.deleted",
137
+ "providers/google.firebase.analytics/eventTypes/event.log" => "google.firebase.analytics.log.v1.written",
138
+ "providers/google.firebase.database/eventTypes/ref.create" => "google.firebase.database.document.v1.created",
139
+ "providers/google.firebase.database/eventTypes/ref.write" => "google.firebase.database.document.v1.written",
140
+ "providers/google.firebase.database/eventTypes/ref.update" => "google.firebase.database.document.v1.updated",
141
+ "providers/google.firebase.database/eventTypes/ref.delete" => "google.firebase.database.document.v1.deleted",
142
+ "providers/cloud.storage/eventTypes/object.change" => "google.cloud.storage.object.v1.finalized"
143
+ }.freeze
144
+ end
145
+ end
@@ -75,42 +75,6 @@ module FunctionsFramework
75
75
  self
76
76
  end
77
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
78
  ##
115
79
  # Add a CloudEvent function to the registry.
116
80
  #
@@ -118,9 +82,6 @@ module FunctionsFramework
118
82
  # function. The block should take _one_ argument: the event object of type
119
83
  # {FunctionsFramework::CloudEvents::Event}. Any return value is ignored.
120
84
  #
121
- # See also {#add_event} which creates a function that takes data and
122
- # context as separate arguments.
123
- #
124
85
  # @param name [String] The function name
125
86
  # @param block [Proc] The function code as a proc
126
87
  # @return [self]
@@ -47,7 +47,7 @@ module FunctionsFramework
47
47
  case function.type
48
48
  when :http
49
49
  HttpApp.new function, @config
50
- when :event, :cloud_event
50
+ when :cloud_event
51
51
  EventApp.new function, @config
52
52
  else
53
53
  raise "Unrecognized function type: #{function.type}"
@@ -212,7 +212,7 @@ module FunctionsFramework
212
212
  # @param bind_addr [String,nil]
213
213
  #
214
214
  def bind_addr= bind_addr
215
- @bind_addr = bind_addr || ::ENV["BIND_ADDR"] || "0.0.0.0"
215
+ @bind_addr = bind_addr || ::ENV["FUNCTION_BIND_ADDR"] || "0.0.0.0"
216
216
  end
217
217
 
218
218
  ##
@@ -228,7 +228,7 @@ module FunctionsFramework
228
228
  # @param min_threads [Integer,nil]
229
229
  #
230
230
  def min_threads= min_threads
231
- @min_threads = (min_threads || ::ENV["MIN_THREADS"])&.to_i
231
+ @min_threads = (min_threads || ::ENV["FUNCTION_MIN_THREADS"])&.to_i
232
232
  end
233
233
 
234
234
  ##
@@ -236,7 +236,7 @@ module FunctionsFramework
236
236
  # @param max_threads [Integer,nil]
237
237
  #
238
238
  def max_threads= max_threads
239
- @max_threads = (max_threads || ::ENV["MAX_THREADS"])&.to_i
239
+ @max_threads = (max_threads || ::ENV["FUNCTION_MAX_THREADS"])&.to_i
240
240
  end
241
241
 
242
242
  ##
@@ -244,8 +244,12 @@ module FunctionsFramework
244
244
  # @param show_error_details [Boolean,nil]
245
245
  #
246
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
247
+ @show_error_details =
248
+ if show_error_details.nil?
249
+ !::ENV["FUNCTION_DETAILED_ERRORS"].to_s.empty?
250
+ else
251
+ show_error_details ? true : false
252
+ end
249
253
  end
250
254
 
251
255
  ##
@@ -315,15 +319,15 @@ module FunctionsFramework
315
319
 
316
320
  ## @private
317
321
  class AppBase
318
- BLACKLISTED_PATHS = ["/favicon.ico", "/robots.txt"].freeze
322
+ EXCLUDED_PATHS = ["/favicon.ico", "/robots.txt"].freeze
319
323
 
320
324
  def initialize config
321
325
  @config = config
322
326
  end
323
327
 
324
- def blacklisted_path? env
328
+ def excluded_path? env
325
329
  path = env[::Rack::SCRIPT_NAME].to_s + env[::Rack::PATH_INFO].to_s
326
- BLACKLISTED_PATHS.include? path
330
+ EXCLUDED_PATHS.include? path
327
331
  end
328
332
 
329
333
  def interpret_response response
@@ -335,15 +339,13 @@ module FunctionsFramework
335
339
  when ::String
336
340
  string_response response, "text/plain", 200
337
341
  when ::Hash
338
- json = ::JSON.dump response
339
- string_response json, "application/json", 200
342
+ string_response ::JSON.dump(response), "application/json", 200
343
+ when CloudEvents::CloudEventsError
344
+ cloud_events_error_response response
340
345
  when ::StandardError
341
- error = error_message response
342
- string_response error, "text/plain", 500
346
+ error_response "#{response.class}: #{response.message}\n#{response.backtrace}\n"
343
347
  else
344
- e = ::StandardError.new "Unexpected response type: #{response.class}"
345
- error = error_message e
346
- string_response error, "text/plain", 500
348
+ error_response "Unexpected response type: #{response.class}"
347
349
  end
348
350
  end
349
351
 
@@ -359,20 +361,15 @@ module FunctionsFramework
359
361
  [status, headers, [string]]
360
362
  end
361
363
 
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
364
+ def cloud_events_error_response error
365
+ @config.logger.warn error
366
+ string_response "#{error.class}: #{error.message}", "text/plain", 400
368
367
  end
369
368
 
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
369
+ def error_response message
370
+ @config.logger.error message
371
+ message = "Unexpected internal error" unless @config.show_error_details?
372
+ string_response message, "text/plain", 500
376
373
  end
377
374
  end
378
375
 
@@ -384,15 +381,15 @@ module FunctionsFramework
384
381
  end
385
382
 
386
383
  def call env
387
- return notfound_response if blacklisted_path? env
384
+ return notfound_response if excluded_path? env
388
385
  response =
389
386
  begin
390
- logger = env["rack.logger"] = @config.logger
387
+ logger = env["rack.logger"] ||= @config.logger
391
388
  request = ::Rack::Request.new env
392
389
  logger.info "FunctionsFramework: Handling HTTP #{request.request_method} request"
393
- @function.call request
390
+ calling_context = @function.new_call logger: logger
391
+ calling_context.call request
394
392
  rescue ::StandardError => e
395
- logger.warn e
396
393
  e
397
394
  end
398
395
  interpret_response response
@@ -404,33 +401,46 @@ module FunctionsFramework
404
401
  def initialize function, config
405
402
  super config
406
403
  @function = function
404
+ @cloud_events = CloudEvents::HttpBinding.default
405
+ @legacy_events = LegacyEventConverter.new
407
406
  end
408
407
 
409
408
  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
409
+ return notfound_response if excluded_path? env
410
+ logger = env["rack.logger"] ||= @config.logger
411
+ event = decode_event env
418
412
  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
413
+ case event
414
+ when CloudEvents::Event
415
+ handle_cloud_event event, logger
416
+ when ::Array
417
+ CloudEvents::HttpContentError.new "Batched CloudEvents are not supported"
418
+ when CloudEvents::CloudEventsError
419
+ event
428
420
  else
429
- logger.warn e.inspect
430
- string_response usage_message(e), "text/plain", 400
421
+ raise "Unexpected event type: #{event.class}"
431
422
  end
432
423
  interpret_response response
433
424
  end
425
+
426
+ private
427
+
428
+ def decode_event env
429
+ @cloud_events.decode_rack_env(env) ||
430
+ @legacy_events.decode_rack_env(env) ||
431
+ raise(CloudEvents::HttpContentError, "Unrecognized event format")
432
+ rescue CloudEvents::CloudEventsError => e
433
+ e
434
+ end
435
+
436
+ def handle_cloud_event event, logger
437
+ logger.info "FunctionsFramework: Handling CloudEvent"
438
+ calling_context = @function.new_call logger: logger
439
+ calling_context.call event
440
+ "ok"
441
+ rescue ::StandardError => e
442
+ e
443
+ end
434
444
  end
435
445
  end
436
446
  end