functions_framework 0.1.0 → 0.3.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,122 @@
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
+ # An implementation of JSON format and JSON batch format.
22
+ #
23
+ # See https://github.com/cloudevents/spec/blob/master/json-format.md
24
+ #
25
+ class JsonFormat
26
+ ##
27
+ # Decode an event from the given input JSON string.
28
+ #
29
+ # @param json [String] A JSON-formatted string
30
+ # @return [FunctionsFramework::CloudEvents::Event]
31
+ #
32
+ def decode json, **_other_kwargs
33
+ structure = ::JSON.parse json
34
+ decode_hash_structure structure
35
+ end
36
+
37
+ ##
38
+ # Encode an event to a JSON string.
39
+ #
40
+ # @param event [FunctionsFramework::CloudEvents::Event] An input event.
41
+ # @param sort [boolean] Whether to sort keys of the JSON output.
42
+ # @return [String] The JSON representation.
43
+ #
44
+ def encode event, sort: false, **_other_kwargs
45
+ structure = encode_hash_structure event
46
+ structure = sort_keys structure if sort
47
+ ::JSON.dump structure
48
+ end
49
+
50
+ ##
51
+ # Decode a batch of events from the given input string.
52
+ #
53
+ # @param json [String] A JSON-formatted string
54
+ # @return [Array<FunctionsFramework::CloudEvents::Event>]
55
+ #
56
+ def decode_batch json, **_other_kwargs
57
+ structure_array = Array(::JSON.parse(json))
58
+ structure_array.map do |structure|
59
+ decode_hash_structure structure
60
+ end
61
+ end
62
+
63
+ ##
64
+ # Encode a batch of event to a JSON string.
65
+ #
66
+ # @param events [Array<FunctionsFramework::CloudEvents::Event>] An array
67
+ # of input events.
68
+ # @param sort [boolean] Whether to sort keys of the JSON output.
69
+ # @return [String] The JSON representation.
70
+ #
71
+ def encode_batch events, sort: false, **_other_kwargs
72
+ structure_array = Array(events).map do |event|
73
+ structure = encode_hash_structure event
74
+ sort ? sort_keys(structure) : structure
75
+ end
76
+ ::JSON.dump structure_array
77
+ end
78
+
79
+ ##
80
+ # Decode a single event from a hash data structure with keys and types
81
+ # conforming to the JSON event format.
82
+ #
83
+ # @param structure [Hash] An input hash.
84
+ # @return [FunctionsFramework::CloudEvents::Event]
85
+ #
86
+ def decode_hash_structure structure
87
+ if structure.key? "data_base64"
88
+ structure = structure.dup
89
+ structure["data"] = ::Base64.decode64 structure.delete "data_base64"
90
+ end
91
+ Event.create spec_version: structure["specversion"], attributes: structure
92
+ end
93
+
94
+ ##
95
+ # Encode a single event to a hash data structure with keys and types
96
+ # conforming to the JSON event format.
97
+ #
98
+ # @param event [FunctionsFramework::CloudEvents::Event] An input event.
99
+ # @return [String] The hash structure.
100
+ #
101
+ def encode_hash_structure event
102
+ structure = event.to_h
103
+ data = structure["data"]
104
+ if data.is_a?(::String) && data.encoding == ::Encoding::ASCII_8BIT
105
+ structure.delete "data"
106
+ structure["data_base64"] = ::Base64.encode64 data
107
+ end
108
+ structure
109
+ end
110
+
111
+ private
112
+
113
+ def sort_keys hash
114
+ result = {}
115
+ hash.keys.sort.each do |key|
116
+ result[key] = hash[key]
117
+ end
118
+ result
119
+ end
120
+ end
121
+ end
122
+ end
@@ -30,7 +30,9 @@ module FunctionsFramework
30
30
  def initialize name, type, &block
31
31
  @name = name
32
32
  @type = type
33
- @block = block
33
+ @execution_context_class = Class.new do
34
+ define_method :call, &block
35
+ end
34
36
  end
35
37
 
36
38
  ##
@@ -43,32 +45,26 @@ module FunctionsFramework
43
45
  #
44
46
  attr_reader :type
45
47
 
46
- ##
47
- # @return [Proc] The function code as a proc
48
- #
49
- attr_reader :block
50
-
51
48
  ##
52
49
  # Call the function. You must pass an argument appropriate to the type
53
50
  # of function.
54
51
  #
55
52
  # * A `:http` type function takes a `Rack::Request` argument, and returns
56
53
  # a Rack response type. See {FunctionsFramework::Registry.add_http}.
57
- # * A `:event` or `:cloud_event` type function takes a
54
+ # * A `:cloud_event` type function takes a
58
55
  # {FunctionsFramework::CloudEvents::Event} argument, and does not
59
56
  # 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
57
  #
63
58
  # @param argument [Rack::Request,FunctionsFramework::CloudEvents::Event]
64
59
  # @return [Object]
65
60
  #
66
61
  def call argument
62
+ execution_context = @execution_context_class.new
67
63
  case type
68
64
  when :event
69
- block.call argument.data, argument
65
+ execution_context.call argument.data, argument
70
66
  else
71
- block.call argument
67
+ execution_context.call argument
72
68
  end
73
69
  end
74
70
  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_prefix == "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
@@ -76,31 +76,10 @@ module FunctionsFramework
76
76
  end
77
77
 
78
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`.
79
+ # This is an obsolete interface that defines an event function taking two
80
+ # arguments (data and context) rather than one.
97
81
  #
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]
82
+ # @deprecated Use {Registry#add_cloud_event} instead.
104
83
  #
105
84
  def add_event name, &block
106
85
  name = name.to_s
@@ -118,9 +97,6 @@ module FunctionsFramework
118
97
  # function. The block should take _one_ argument: the event object of type
119
98
  # {FunctionsFramework::CloudEvents::Event}. Any return value is ignored.
120
99
  #
121
- # See also {#add_event} which creates a function that takes data and
122
- # context as separate arguments.
123
- #
124
100
  # @param name [String] The function name
125
101
  # @param block [Proc] The function code as a proc
126
102
  # @return [self]
@@ -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,10 +319,17 @@ module FunctionsFramework
315
319
 
316
320
  ## @private
317
321
  class AppBase
322
+ EXCLUDED_PATHS = ["/favicon.ico", "/robots.txt"].freeze
323
+
318
324
  def initialize config
319
325
  @config = config
320
326
  end
321
327
 
328
+ def excluded_path? env
329
+ path = env[::Rack::SCRIPT_NAME].to_s + env[::Rack::PATH_INFO].to_s
330
+ EXCLUDED_PATHS.include? path
331
+ end
332
+
322
333
  def interpret_response response
323
334
  case response
324
335
  when ::Array
@@ -328,18 +339,20 @@ module FunctionsFramework
328
339
  when ::String
329
340
  string_response response, "text/plain", 200
330
341
  when ::Hash
331
- json = ::JSON.dump response
332
- 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
333
345
  when ::StandardError
334
- error = error_message response
335
- string_response error, "text/plain", 500
346
+ error_response "#{response.class}: #{response.message}\n#{response.backtrace}\n"
336
347
  else
337
- e = ::StandardError.new "Unexpected response type: #{response.class}"
338
- error = error_message e
339
- string_response error, "text/plain", 500
348
+ error_response "Unexpected response type: #{response.class}"
340
349
  end
341
350
  end
342
351
 
352
+ def notfound_response
353
+ string_response "Not found", "text/plain", 404
354
+ end
355
+
343
356
  def string_response string, content_type, status
344
357
  headers = {
345
358
  "Content-Type" => content_type,
@@ -348,20 +361,15 @@ module FunctionsFramework
348
361
  [status, headers, [string]]
349
362
  end
350
363
 
351
- def error_message error
352
- if @config.show_error_details?
353
- "#{error.class}: #{error.message}\n#{error.backtrace}\n"
354
- else
355
- "Unexpected internal error"
356
- end
364
+ def cloud_events_error_response error
365
+ @config.logger.warn error
366
+ string_response "#{error.class}: #{error.message}", "text/plain", 400
357
367
  end
358
368
 
359
- def usage_message error
360
- if @config.show_error_details?
361
- "Failed to decode CloudEvent: #{error.inspect}"
362
- else
363
- "Failed to decode CloudEvent"
364
- 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
365
373
  end
366
374
  end
367
375
 
@@ -373,6 +381,7 @@ module FunctionsFramework
373
381
  end
374
382
 
375
383
  def call env
384
+ return notfound_response if excluded_path? env
376
385
  response =
377
386
  begin
378
387
  logger = env["rack.logger"] = @config.logger
@@ -380,7 +389,6 @@ module FunctionsFramework
380
389
  logger.info "FunctionsFramework: Handling HTTP #{request.request_method} request"
381
390
  @function.call request
382
391
  rescue ::StandardError => e
383
- logger.warn e
384
392
  e
385
393
  end
386
394
  interpret_response response
@@ -392,32 +400,45 @@ module FunctionsFramework
392
400
  def initialize function, config
393
401
  super config
394
402
  @function = function
403
+ @cloud_events = CloudEvents::HttpBinding.default
404
+ @legacy_events = LegacyEventConverter.new
395
405
  end
396
406
 
397
407
  def call env
408
+ return notfound_response if excluded_path? env
398
409
  logger = env["rack.logger"] = @config.logger
399
- event =
400
- begin
401
- CloudEvents.decode_rack_env env
402
- rescue ::StandardError => e
403
- e
404
- end
410
+ event = decode_event env
405
411
  response =
406
- if event.is_a? CloudEvents::Event
407
- logger.info "FunctionsFramework: Handling CloudEvent"
408
- begin
409
- @function.call event
410
- "ok"
411
- rescue ::StandardError => e
412
- logger.warn e
413
- e
414
- end
412
+ case event
413
+ when CloudEvents::Event
414
+ handle_cloud_event event, logger
415
+ when ::Array
416
+ CloudEvents::HttpContentError.new "Batched CloudEvents are not supported"
417
+ when CloudEvents::CloudEventsError
418
+ event
415
419
  else
416
- logger.warn e.inspect
417
- string_response usage_message(e), "text/plain", 400
420
+ raise "Unexpected event type: #{event.class}"
418
421
  end
419
422
  interpret_response response
420
423
  end
424
+
425
+ private
426
+
427
+ def decode_event env
428
+ @cloud_events.decode_rack_env(env) ||
429
+ @legacy_events.decode_rack_env(env) ||
430
+ raise(CloudEvents::HttpContentError, "Unrecognized event format")
431
+ rescue CloudEvents::CloudEventsError => e
432
+ e
433
+ end
434
+
435
+ def handle_cloud_event event, logger
436
+ logger.info "FunctionsFramework: Handling CloudEvent"
437
+ @function.call event
438
+ "ok"
439
+ rescue ::StandardError => e
440
+ e
441
+ end
421
442
  end
422
443
  end
423
444
  end