functions_framework 0.6.0 → 0.10.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.
@@ -175,10 +175,8 @@ module FunctionsFramework
175
175
  # run only when preparing to run functions. They are not run, for example,
176
176
  # if an app is loaded to verify its integrity during deployment.
177
177
  #
178
- # Startup tasks are passed two arguments: the {FunctionsFramework::Function}
179
- # identifying the function to execute, and the
180
- # {FunctionsFramework::Server::Config} specifying the (frozen) server
181
- # configuration. Tasks have no return value.
178
+ # Startup tasks are passed the {FunctionsFramework::Function} identifying
179
+ # the function to execute, and have no return value.
182
180
  #
183
181
  # @param block [Proc] The startup task
184
182
  # @return [self]
@@ -189,8 +187,9 @@ module FunctionsFramework
189
187
  end
190
188
 
191
189
  ##
192
- # Start the functions framework server in the background. The server will
193
- # look up the given target function name in the global registry.
190
+ # Run startup tasks, then start the functions framework server in the
191
+ # background. The startup tasks and target function will be looked up in
192
+ # the global registry.
194
193
  #
195
194
  # @param target [FunctionsFramework::Function,String] The function to run,
196
195
  # or the name of the function to look up in the global registry.
@@ -206,8 +205,12 @@ module FunctionsFramework
206
205
  function = global_registry[target]
207
206
  raise ::ArgumentError, "Undefined function: #{target.inspect}" if function.nil?
208
207
  end
209
- server = Server.new function, &block
210
- global_registry.run_startup_tasks server
208
+ globals = function.populate_globals
209
+ server = Server.new function, globals, &block
210
+ global_registry.startup_tasks.each do |task|
211
+ task.call function, globals: globals, logger: server.config.logger
212
+ end
213
+ globals.freeze
211
214
  server.respond_to_signals
212
215
  server.start
213
216
  end
@@ -74,7 +74,7 @@ module FunctionsFramework
74
74
  # @param argv [Array<String>]
75
75
  # @return [self]
76
76
  #
77
- def parse_args argv # rubocop:disable Metrics/MethodLength
77
+ def parse_args argv # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
78
78
  @option_parser = ::OptionParser.new do |op| # rubocop:disable Metrics/BlockLength
79
79
  op.on "-t", "--target TARGET",
80
80
  "Set the name of the function to execute (defaults to #{DEFAULT_TARGET})" do |val|
@@ -18,44 +18,91 @@ module FunctionsFramework
18
18
  #
19
19
  # A function has a name, a type, and an implementation.
20
20
  #
21
+ # ## Function implementations
22
+ #
21
23
  # 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
- # [CloudEvent](https://cloudevents.github.io/sdk-ruby/latest/CloudEvents/Event)
27
- # argument, and does not return a value.
28
- # See {FunctionsFramework::Registry.add_cloud_event}.
24
+ # method.
25
+ #
26
+ # * For a function of type `:http`, the `call` method takes a single
27
+ # `Rack::Request` argument and returns one of various HTTP response
28
+ # types. See {FunctionsFramework::Registry.add_http}.
29
+ # * For a function of type `:cloud_event`, the `call` method takes a single
30
+ # [CloudEvent](https://cloudevents.github.io/sdk-ruby/latest/CloudEvents/Event)
31
+ # argument, and does not return a value. See
32
+ # {FunctionsFramework::Registry.add_cloud_event}.
33
+ # * For a function of type `:startup_task`, the `call` method takes a
34
+ # single {FunctionsFramework::Function} argument, and does not return a
35
+ # value. See {FunctionsFramework::Registry.add_startup_task}.
29
36
  #
30
- # If a callable object is provided directly, its `call` method is invoked for
31
- # every function execution. Note that this means it may be called multiple
32
- # times concurrently in separate threads.
37
+ # The implementation can be specified in one of three ways:
33
38
  #
34
- # Alternately, the implementation may be provided as a class that should be
35
- # instantiated to produce a callable object. If a class is provided, it should
36
- # either subclass {FunctionsFramework::Function::CallBase} or respond to the
37
- # same constructor interface, i.e. accepting arbitrary keyword arguments. A
38
- # separate callable object will be instantiated from this class for every
39
- # function invocation, so each instance will be used for only one invocation.
39
+ # * A callable object can be passed in the `callable` keyword argument. The
40
+ # object's `call` method will be invoked for every function execution.
41
+ # Note that this means it may be called multiple times concurrently in
42
+ # separate threads.
43
+ # * A callable _class_ can be passed in the `callable` keyword argument.
44
+ # This class should subclass {FunctionsFramework::Function::Callable} and
45
+ # define the `call` method. A separate instance of this class will be
46
+ # created for each function invocation.
47
+ # * A block can be provided. It will be used to define the `call` method in
48
+ # an anonymous subclass of {FunctionsFramework::Function::Callable}.
49
+ # Thus, providing a block is really just syntactic sugar for providing a
50
+ # class. (This means, for example, that the `return` keyword will work
51
+ # as expected within the block because it is treated as a method.)
40
52
  #
41
- # Finally, an implementation can be provided as a block. If a block is
42
- # provided, it will be recast as a `call` method in an anonymous subclass of
43
- # {FunctionsFramework::Function::CallBase}. Thus, providing a block is really
44
- # just syntactic sugar for providing a class. (This means, for example, that
45
- # the `return` keyword will work within the block because it is treated as a
46
- # method.)
53
+ # When the implementation is provided as a callable class or block, it is
54
+ # executed in the context of a {FunctionsFramework::Function::Callable}
55
+ # object. This object provides a convenience accessor for the Logger, and
56
+ # access to _globals_, which are data defined by the application startup
57
+ # process and available to each function invocation. Typically, globals are
58
+ # used for shared global resources such as service connections and clients.
47
59
  #
48
60
  class Function
61
+ ##
62
+ # Create a new HTTP function definition.
63
+ #
64
+ # @param name [String] The function name
65
+ # @param callable [Class,#call] A callable object or class.
66
+ # @param block [Proc] The function code as a block.
67
+ # @return [FunctionsFramework::Function]
68
+ #
69
+ def self.http name, callable: nil, &block
70
+ new name, :http, callable: callable, &block
71
+ end
72
+
73
+ ##
74
+ # Create a new CloudEvents function definition.
75
+ #
76
+ # @param name [String] The function name
77
+ # @param callable [Class,#call] A callable object or class.
78
+ # @param block [Proc] The function code as a block.
79
+ # @return [FunctionsFramework::Function]
80
+ #
81
+ def self.cloud_event name, callable: nil, &block
82
+ new name, :cloud_event, callable: callable, &block
83
+ end
84
+
85
+ ##
86
+ # Create a new startup task function definition.
87
+ #
88
+ # @param callable [Class,#call] A callable object or class.
89
+ # @param block [Proc] The function code as a block.
90
+ # @return [FunctionsFramework::Function]
91
+ #
92
+ def self.startup_task callable: nil, &block
93
+ new nil, :startup_task, callable: callable, &block
94
+ end
95
+
49
96
  ##
50
97
  # Create a new function definition.
51
98
  #
52
99
  # @param name [String] The function name
53
- # @param type [Symbol] The type of function. Valid types are `:http` and
54
- # `:cloud_event`.
100
+ # @param type [Symbol] The type of function. Valid types are `:http`,
101
+ # `:cloud_event`, and `:startup_task`.
55
102
  # @param callable [Class,#call] A callable object or class.
56
103
  # @param block [Proc] The function code as a block.
57
104
  #
58
- def initialize name, type, callable = nil, &block
105
+ def initialize name, type, callable: nil, &block
59
106
  @name = name
60
107
  @type = type
61
108
  @callable = @callable_class = nil
@@ -64,7 +111,7 @@ module FunctionsFramework
64
111
  elsif callable.is_a? ::Class
65
112
  @callable_class = callable
66
113
  elsif block_given?
67
- @callable_class = ::Class.new CallBase do
114
+ @callable_class = ::Class.new Callable do
68
115
  define_method :call, &block
69
116
  end
70
117
  else
@@ -83,18 +130,60 @@ module FunctionsFramework
83
130
  attr_reader :type
84
131
 
85
132
  ##
86
- # Get a callable for performing a function invocation. This will either
87
- # return the singleton callable object, or instantiate a new callable from
88
- # the configured class.
89
- #
90
- # @param logger [::Logger] The logger for use by function executions. This
91
- # may or may not be used by the callable.
92
- # @return [#call]
93
- #
94
- def new_call logger: nil
95
- return @callable unless @callable.nil?
96
- logger ||= FunctionsFramework.logger
97
- @callable_class.new logger: logger, function_name: name, function_type: type
133
+ # Populate the given globals hash with this function's info.
134
+ #
135
+ # @param globals [Hash] Initial globals hash (optional).
136
+ # @return [Hash] A new globals hash with this function's info included.
137
+ #
138
+ def populate_globals globals = nil
139
+ result = { function_name: name, function_type: type }
140
+ result.merge! globals if globals
141
+ result
142
+ end
143
+
144
+ ##
145
+ # Call the function given a set of arguments. Set the given logger and/or
146
+ # globals in the context if the callable supports it.
147
+ #
148
+ # If the given arguments exceeds what the function will accept, the args
149
+ # are silently truncated. However, if the function requires more arguments
150
+ # than are provided, an ArgumentError is raised.
151
+ #
152
+ # @param args [Array] Argument to pass to the function.
153
+ # @param logger [Logger] Logger for use by function executions.
154
+ # @param globals [Hash] Globals for the function execution context
155
+ # @return [Object] The function return value.
156
+ #
157
+ def call *args, globals: nil, logger: nil
158
+ callable = @callable || @callable_class.new(globals: globals, logger: logger)
159
+ params = callable.method(:call).parameters.map(&:first)
160
+ unless params.include? :rest
161
+ max_params = params.count(:req) + params.count(:opt)
162
+ args = args.take max_params
163
+ end
164
+ callable.call(*args)
165
+ end
166
+
167
+ ##
168
+ # A lazy evaluator for a global
169
+ # @private
170
+ #
171
+ class LazyGlobal
172
+ def initialize block
173
+ @block = block
174
+ @value = nil
175
+ @mutex = ::Mutex.new
176
+ end
177
+
178
+ def value
179
+ @mutex.synchronize do
180
+ if @block
181
+ @value = @block.call
182
+ @block = nil
183
+ end
184
+ @value
185
+ end
186
+ end
98
187
  end
99
188
 
100
189
  ##
@@ -102,29 +191,82 @@ module FunctionsFramework
102
191
  #
103
192
  # An object of this class is `self` while a function block is running.
104
193
  #
105
- class CallBase
194
+ class Callable
106
195
  ##
107
196
  # Create a callable object with the given context.
108
197
  #
109
- # @param context [keywords] A set of context arguments. See {#context} for
110
- # a list of keys that will generally be passed in. However,
111
- # implementations should be prepared to accept any abritrary keys.
198
+ # @param globals [Hash] A set of globals available to the call.
199
+ # @param logger [Logger] A logger for use by the function call.
112
200
  #
113
- def initialize **context
114
- @context = context
201
+ def initialize globals: nil, logger: nil
202
+ @__globals = globals || {}
203
+ @__logger = logger || FunctionsFramework.logger
115
204
  end
116
205
 
117
206
  ##
118
- # A keyed hash of context information. Common context keys include:
207
+ # Get the given named global.
208
+ #
209
+ # For most function calls, the following globals will be defined:
119
210
  #
120
- # * **:logger** (`Logger`) A logger for use by this function call.
121
211
  # * **:function_name** (`String`) The name of the running function.
122
212
  # * **:function_type** (`Symbol`) The type of the running function,
123
213
  # either `:http` or `:cloud_event`.
124
214
  #
125
- # @return [Hash]
215
+ # You can also set additional globals from a startup task.
216
+ #
217
+ # @param key [Symbol,String] The name of the global to get.
218
+ # @return [Object]
219
+ #
220
+ def global key
221
+ value = @__globals[key]
222
+ value = value.value if LazyGlobal === value
223
+ value
224
+ end
225
+
226
+ ##
227
+ # Set a global. This can be called from startup tasks, but the globals
228
+ # are frozen when the server starts, so this call will raise an exception
229
+ # if called from a normal function.
230
+ #
231
+ # You can set a global to a final value, or you can provide a block that
232
+ # lazily computes the global the first time it is requested.
233
+ #
234
+ # @overload set_global(key, value)
235
+ # Set the given global to the given value. For example:
236
+ #
237
+ # set_global(:project_id, "my-project-id")
238
+ #
239
+ # @param key [Symbol,String]
240
+ # @param value [Object]
241
+ # @return [self]
242
+ #
243
+ # @overload set_global(key, &block)
244
+ # Call the given block to compute the global's value only when the
245
+ # value is actually requested. This block will be called at most once,
246
+ # and its result reused for subsequent calls. For example:
247
+ #
248
+ # set_global(:connection_pool) do
249
+ # ExpensiveConnectionPool.new
250
+ # end
126
251
  #
127
- attr_reader :context
252
+ # @param key [Symbol,String]
253
+ # @param block [Proc] A block that lazily computes a value
254
+ # @yieldreturn [Object] The value
255
+ # @return [self]
256
+ #
257
+ def set_global key, value = nil, &block
258
+ @__globals[key] = block ? LazyGlobal.new(block) : value
259
+ self
260
+ end
261
+
262
+ ##
263
+ # A logger for use by this call.
264
+ #
265
+ # @return [Logger]
266
+ #
267
+ def logger
268
+ @__logger
269
+ end
128
270
  end
129
271
  end
130
272
  end
@@ -27,20 +27,21 @@ module FunctionsFramework
27
27
  # @return [nil] if the event format was not recognized.
28
28
  #
29
29
  def decode_rack_env env
30
- content_type = ::CloudEvents::ContentType.new env["CONTENT_TYPE"]
30
+ content_type = ::CloudEvents::ContentType.new env["CONTENT_TYPE"], default_charset: "utf-8"
31
31
  return nil unless content_type.media_type == "application" && content_type.subtype_base == "json"
32
32
  input = read_input_json env["rack.input"], content_type.charset
33
33
  return nil unless input
34
+ input = convert_raw_pubsub_event input, env if raw_pubsub_payload? input
34
35
  context = normalized_context input
35
36
  return nil unless context
36
- construct_cloud_event context, input["data"], content_type.charset
37
+ construct_cloud_event context, input["data"]
37
38
  end
38
39
 
39
40
  private
40
41
 
41
42
  def read_input_json input, charset
42
43
  input = input.read if input.respond_to? :read
43
- input = input.encode charset if charset
44
+ input.force_encoding charset if charset
44
45
  content = ::JSON.parse input
45
46
  content = nil unless content.is_a? ::Hash
46
47
  content
@@ -48,17 +49,51 @@ module FunctionsFramework
48
49
  nil
49
50
  end
50
51
 
52
+ def raw_pubsub_payload? input
53
+ return false if input.include?("context") || !input.include?("subscription")
54
+ message = input["message"]
55
+ message.is_a?(::Hash) && message.include?("data") && message.include?("messageId")
56
+ end
57
+
58
+ def convert_raw_pubsub_event input, env
59
+ message = input["message"]
60
+ path = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
61
+ path_match = %r{projects/[^/?]+/topics/[^/?]+}.match path
62
+ topic = path_match ? path_match[0] : "UNKNOWN_PUBSUB_TOPIC"
63
+ timestamp = message["publishTime"] || ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ")
64
+ {
65
+ "context" => {
66
+ "eventId" => message["messageId"],
67
+ "timestamp" => timestamp,
68
+ "eventType" => "google.pubsub.topic.publish",
69
+ "resource" => {
70
+ "service" => "pubsub.googleapis.com",
71
+ "type" => "type.googleapis.com/google.pubsub.v1.PubsubMessage",
72
+ "name" => topic
73
+ }
74
+ },
75
+ "data" => {
76
+ "@type" => "type.googleapis.com/google.pubsub.v1.PubsubMessage",
77
+ "data" => message["data"],
78
+ "attributes" => message["attributes"]
79
+ }
80
+ }
81
+ end
82
+
51
83
  def normalized_context input
52
- raw_context = input["context"]
53
- id = raw_context&.[]("eventId") || input["eventId"]
54
- timestamp = raw_context&.[]("timestamp") || input["timestamp"]
55
- type = raw_context&.[]("eventType") || input["eventType"]
56
- service, resource = analyze_resource raw_context&.[]("resource") || input["resource"]
84
+ id = normalized_context_field input, "eventId"
85
+ timestamp = normalized_context_field input, "timestamp"
86
+ type = normalized_context_field input, "eventType"
87
+ service, resource = analyze_resource normalized_context_field input, "resource"
57
88
  service ||= service_from_type type
58
89
  return nil unless id && timestamp && type && service && resource
59
90
  { id: id, timestamp: timestamp, type: type, service: service, resource: resource }
60
91
  end
61
92
 
93
+ def normalized_context_field input, field
94
+ input["context"]&.[](field) || input[field]
95
+ end
96
+
62
97
  def analyze_resource raw_resource
63
98
  service = resource = nil
64
99
  case raw_resource
@@ -78,37 +113,47 @@ module FunctionsFramework
78
113
  nil
79
114
  end
80
115
 
81
- def construct_cloud_event context, data, charset
116
+ def construct_cloud_event context, data
82
117
  source, subject = convert_source context[:service], context[:resource]
83
118
  type = LEGACY_TYPE_TO_CE_TYPE[context[:type]]
84
119
  return nil unless type && source
85
- ce_data = convert_data context[:service], data
86
- content_type = "application/json; charset=#{charset}"
120
+ ce_data, data_subject = convert_data context[:service], data
121
+ content_type = "application/json"
87
122
  ::CloudEvents::Event.new id: context[:id],
88
123
  source: source,
89
124
  type: type,
90
125
  spec_version: "1.0",
91
126
  data_content_type: content_type,
92
127
  data: ce_data,
93
- subject: subject,
128
+ subject: subject || data_subject,
94
129
  time: context[:timestamp]
95
130
  end
96
131
 
97
132
  def convert_source service, resource
98
- if service == "storage.googleapis.com"
99
- match = %r{^(projects/[^/]+/buckets/[^/]+)/([^#]+)(?:#.*)?$}.match resource
100
- return [nil, nil] unless match
101
- ["//#{service}/#{match[1]}", match[2]]
102
- else
103
- ["//#{service}/#{resource}", nil]
104
- end
133
+ return ["//#{service}/#{resource}", nil] unless CE_SERVICE_TO_RESOURCE_RE.key? service
134
+
135
+ match = CE_SERVICE_TO_RESOURCE_RE[service].match resource
136
+ return [nil, nil] unless match
137
+ ["//#{service}/#{match[1]}", match[2]]
105
138
  end
106
139
 
107
140
  def convert_data service, data
108
- if service == "pubsub.googleapis.com"
109
- { "message" => data, "subscription" => nil }
141
+ case service
142
+ when "pubsub.googleapis.com"
143
+ [{ "message" => data }, nil]
144
+ when "firebaseauth.googleapis.com"
145
+ if data.key? "metadata"
146
+ FIREBASE_AUTH_METADATA_LEGACY_TO_CE.each do |old_key, new_key|
147
+ if data["metadata"].key? old_key
148
+ data["metadata"][new_key] = data["metadata"][old_key]
149
+ data["metadata"].delete old_key
150
+ end
151
+ end
152
+ end
153
+ subject = "users/#{data['uid']}" if data.key? "uid"
154
+ [data, subject]
110
155
  else
111
- data
156
+ [data, nil]
112
157
  end
113
158
  end
114
159
 
@@ -116,8 +161,9 @@ module FunctionsFramework
116
161
  %r{^providers/cloud\.firestore/} => "firestore.googleapis.com",
117
162
  %r{^providers/cloud\.pubsub/} => "pubsub.googleapis.com",
118
163
  %r{^providers/cloud\.storage/} => "storage.googleapis.com",
119
- %r{^providers/firebase\.auth/} => "firebase.googleapis.com",
120
- %r{^providers/google\.firebase} => "firebase.googleapis.com"
164
+ %r{^providers/firebase\.auth/} => "firebaseauth.googleapis.com",
165
+ %r{^providers/google\.firebase\.analytics/} => "firebase.googleapis.com",
166
+ %r{^providers/google\.firebase\.database/} => "firebasedatabase.googleapis.com"
121
167
  }.freeze
122
168
 
123
169
  LEGACY_TYPE_TO_CE_TYPE = {
@@ -140,5 +186,18 @@ module FunctionsFramework
140
186
  "providers/google.firebase.database/eventTypes/ref.delete" => "google.firebase.database.document.v1.deleted",
141
187
  "providers/cloud.storage/eventTypes/object.change" => "google.cloud.storage.object.v1.finalized"
142
188
  }.freeze
189
+
190
+ CE_SERVICE_TO_RESOURCE_RE = {
191
+ "firebase.googleapis.com" => %r{^(projects/[^/]+)/(events/[^/]+)$},
192
+ "firebasedatabase.googleapis.com" => %r{^(projects/_/instances/[^/]+)/(refs/.+)$},
193
+ "firestore.googleapis.com" => %r{^(projects/[^/]+/databases/\(default\))/(documents/.+)$},
194
+ "storage.googleapis.com" => %r{^(projects/[^/]+/buckets/[^/]+)/([^#]+)(?:#.*)?$}
195
+ }.freeze
196
+
197
+ # Map Firebase Auth legacy event metadata field names to their equivalent CloudEvent field names.
198
+ FIREBASE_AUTH_METADATA_LEGACY_TO_CE = {
199
+ "createdAt" => "createTime",
200
+ "lastSignedInAt" => "lastSignInTime"
201
+ }.freeze
143
202
  end
144
203
  end