functions_framework 0.1.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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