functions_framework 0.1.0 → 0.3.1

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