functions_framework 0.2.0 → 0.4.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.
@@ -24,7 +24,9 @@ module FunctionsFramework
24
24
  # It supports binary (i.e. header-based) HTTP content, as well as structured
25
25
  # (body-based) content that can delegate to formatters such as JSON.
26
26
  #
27
- # See https://github.com/cloudevents/spec/blob/master/http-protocol-binding.md
27
+ # Supports the CloudEvents 0.3 and CloudEvents 1.0 variants of this format.
28
+ # See https://github.com/cloudevents/spec/blob/v0.3/http-transport-binding.md
29
+ # and https://github.com/cloudevents/spec/blob/v1.0/http-protocol-binding.md.
28
30
  #
29
31
  class HttpBinding
30
32
  ##
@@ -101,7 +103,7 @@ module FunctionsFramework
101
103
  content_type = ContentType.new content_type_header if content_type_header
102
104
  input = env["rack.input"]
103
105
  if input && content_type&.media_type == "application"
104
- case content_type.subtype_prefix
106
+ case content_type.subtype_base
105
107
  when "cloudevents"
106
108
  input.set_encoding content_type.charset if content_type.charset
107
109
  return decode_structured_content input.read, content_type.subtype_format, **format_args
@@ -177,7 +179,7 @@ module FunctionsFramework
177
179
  match = /^HTTP_CE_(\w+)$/.match key
178
180
  next unless match
179
181
  attr_name = match[1].downcase
180
- attributes[attr_name] = value unless omit_names.include? attr_name
182
+ attributes[attr_name] = percent_decode value unless omit_names.include? attr_name
181
183
  end
182
184
  Event.create spec_version: spec_version, attributes: attributes
183
185
  end
@@ -248,7 +250,7 @@ module FunctionsFramework
248
250
  elsif key == "datacontenttype"
249
251
  headers["Content-Type"] = value
250
252
  else
251
- headers["CE-#{key}"] = value
253
+ headers["CE-#{key}"] = percent_encode value
252
254
  end
253
255
  end
254
256
  if body.is_a? ::String
@@ -265,6 +267,44 @@ module FunctionsFramework
265
267
  end
266
268
  [headers, body]
267
269
  end
270
+
271
+ ##
272
+ # Decode a percent-encoded string to a UTF-8 string.
273
+ #
274
+ # @param str [String] Incoming ascii string from an HTTP header, with one
275
+ # cycle of percent-encoding.
276
+ # @return [String] Resulting decoded string in UTF-8.
277
+ #
278
+ def percent_decode str
279
+ decoded_str = str.gsub(/%[0-9a-fA-F]{2}/) { |m| [m[1..-1].to_i(16)].pack "C" }
280
+ decoded_str.force_encoding ::Encoding::UTF_8
281
+ end
282
+
283
+ ##
284
+ # Transcode an arbitrarily-encoded string to UTF-8, then percent-encode
285
+ # non-printing and non-ascii characters to result in an ASCII string
286
+ # suitable for setting as an HTTP header value.
287
+ #
288
+ # @param str [String] Incoming arbitrary string that can be represented
289
+ # in UTF-8.
290
+ # @return [String] Resulting encoded string in ASCII.
291
+ #
292
+ def percent_encode str
293
+ arr = []
294
+ utf_str = str.to_s.encode ::Encoding::UTF_8
295
+ utf_str.each_byte do |byte|
296
+ if byte >= 33 && byte <= 126 && byte != 37
297
+ arr << byte
298
+ else
299
+ hi = byte / 16
300
+ hi = hi > 9 ? 55 + hi : 48 + hi
301
+ lo = byte % 16
302
+ lo = lo > 9 ? 55 + lo : 48 + lo
303
+ arr << 37 << hi << lo
304
+ end
305
+ end
306
+ arr.pack "C*"
307
+ end
268
308
  end
269
309
  end
270
310
  end
@@ -20,7 +20,9 @@ module FunctionsFramework
20
20
  ##
21
21
  # An implementation of JSON format and JSON batch format.
22
22
  #
23
- # See https://github.com/cloudevents/spec/blob/master/json-format.md
23
+ # Supports the CloudEvents 0.3 and CloudEvents 1.0 variants of this format.
24
+ # See https://github.com/cloudevents/spec/blob/v0.3/json-format.md and
25
+ # https://github.com/cloudevents/spec/blob/v1.0/json-format.md.
24
26
  #
25
27
  class JsonFormat
26
28
  ##
@@ -78,34 +80,39 @@ module FunctionsFramework
78
80
 
79
81
  ##
80
82
  # Decode a single event from a hash data structure with keys and types
81
- # conforming to the JSON event format.
83
+ # conforming to the JSON envelope.
82
84
  #
83
85
  # @param structure [Hash] An input hash.
84
86
  # @return [FunctionsFramework::CloudEvents::Event]
85
87
  #
86
88
  def decode_hash_structure structure
87
- if structure.key? "data_base64"
88
- structure = structure.dup
89
- structure["data"] = ::Base64.decode64 structure.delete "data_base64"
89
+ spec_version = structure["specversion"].to_s
90
+ case spec_version
91
+ when "0.3"
92
+ decode_hash_structure_v0 structure
93
+ when /^1(\.|$)/
94
+ decode_hash_structure_v1 structure
95
+ else
96
+ raise SpecVersionError, "Unrecognized specversion: #{spec_version}"
90
97
  end
91
- Event.create spec_version: structure["specversion"], attributes: structure
92
98
  end
93
99
 
94
100
  ##
95
101
  # Encode a single event to a hash data structure with keys and types
96
- # conforming to the JSON event format.
102
+ # conforming to the JSON envelope.
97
103
  #
98
104
  # @param event [FunctionsFramework::CloudEvents::Event] An input event.
99
105
  # @return [String] The hash structure.
100
106
  #
101
107
  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
108
+ case event
109
+ when Event::V0
110
+ encode_hash_structure_v0 event
111
+ when Event::V1
112
+ encode_hash_structure_v1 event
113
+ else
114
+ raise SpecVersionError, "Unrecognized specversion: #{event.spec_version}"
107
115
  end
108
- structure
109
116
  end
110
117
 
111
118
  private
@@ -117,6 +124,50 @@ module FunctionsFramework
117
124
  end
118
125
  result
119
126
  end
127
+
128
+ def decode_hash_structure_v0 structure
129
+ data = structure["data"]
130
+ content_type = structure["datacontenttype"]
131
+ if data.is_a?(::String) && content_type.is_a?(::String)
132
+ content_type = ContentType.new content_type
133
+ if content_type.subtype == "json" || content_type.subtype_format == "json"
134
+ structure = structure.dup
135
+ structure["data"] = ::JSON.parse data rescue data
136
+ structure["datacontenttype"] = content_type
137
+ end
138
+ end
139
+ Event::V0.new attributes: structure
140
+ end
141
+
142
+ def decode_hash_structure_v1 structure
143
+ if structure.key? "data_base64"
144
+ structure = structure.dup
145
+ structure["data"] = ::Base64.decode64 structure.delete "data_base64"
146
+ end
147
+ Event::V1.new attributes: structure
148
+ end
149
+
150
+ def encode_hash_structure_v0 event
151
+ structure = event.to_h
152
+ data = event.data
153
+ content_type = event.data_content_type
154
+ if data.is_a?(::String) && !content_type.nil?
155
+ if content_type.subtype == "json" || content_type.subtype_format == "json"
156
+ structure["data"] = ::JSON.parse data rescue data
157
+ end
158
+ end
159
+ structure
160
+ end
161
+
162
+ def encode_hash_structure_v1 event
163
+ structure = event.to_h
164
+ data = structure["data"]
165
+ if data.is_a?(::String) && data.encoding == ::Encoding::ASCII_8BIT
166
+ structure.delete "data"
167
+ structure["data_base64"] = ::Base64.encode64 data
168
+ end
169
+ structure
170
+ end
120
171
  end
121
172
  end
122
173
  end
@@ -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,30 +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 `:cloud_event` type function takes a
58
- # {FunctionsFramework::CloudEvents::Event} argument, and does not
59
- # return a value. See {FunctionsFramework::Registry.add_cloud_event}.
100
+ # A base class for a callable object that provides calling context.
60
101
  #
61
- # @param argument [Rack::Request,FunctionsFramework::CloudEvents::Event]
62
- # @return [Object]
102
+ # An object of this class is `self` while a function block is running.
63
103
  #
64
- def call argument
65
- case type
66
- when :event
67
- block.call argument.data, argument
68
- else
69
- 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
70
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
71
127
  end
72
128
  end
73
129
  end
@@ -29,13 +29,12 @@ module FunctionsFramework
29
29
  #
30
30
  def decode_rack_env env
31
31
  content_type = CloudEvents::ContentType.new env["CONTENT_TYPE"]
32
- return nil unless content_type.media_type == "application" && content_type.subtype_prefix == "json"
32
+ return nil unless content_type.media_type == "application" && content_type.subtype_base == "json"
33
33
  input = read_input_json env["rack.input"], content_type.charset
34
34
  return nil unless input
35
- raw_context = input["context"] || input
36
- context = normalized_context raw_context
35
+ context = normalized_context input
37
36
  return nil unless context
38
- construct_cloud_event context, input["data"]
37
+ construct_cloud_event context, input["data"], content_type.charset
39
38
  end
40
39
 
41
40
  private
@@ -50,27 +49,27 @@ module FunctionsFramework
50
49
  nil
51
50
  end
52
51
 
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
52
+ def normalized_context input
53
+ raw_context = input["context"]
54
+ id = raw_context&.[]("eventId") || input["eventId"]
55
+ timestamp = raw_context&.[]("timestamp") || input["timestamp"]
56
+ type = raw_context&.[]("eventType") || input["eventType"]
57
+ service, resource = analyze_resource raw_context&.[]("resource") || input["resource"]
58
+ service ||= service_from_type type
59
+ return nil unless id && timestamp && type && service && resource
62
60
  { id: id, timestamp: timestamp, type: type, service: service, resource: resource }
63
61
  end
64
62
 
65
- def analyze_resource raw_resource, type
63
+ def analyze_resource raw_resource
64
+ service = resource = nil
66
65
  case raw_resource
67
66
  when ::Hash
68
- [raw_resource["service"], raw_resource["name"]]
67
+ service = raw_resource["service"]
68
+ resource = raw_resource["name"]
69
69
  when ::String
70
- [service_from_type(type), raw_resource]
71
- else
72
- [nil, nil]
70
+ resource = raw_resource
73
71
  end
72
+ [service, resource]
74
73
  end
75
74
 
76
75
  def service_from_type type
@@ -80,16 +79,18 @@ module FunctionsFramework
80
79
  nil
81
80
  end
82
81
 
83
- def construct_cloud_event context, data
82
+ def construct_cloud_event context, data, charset
84
83
  source, subject = convert_source context[:service], context[:resource]
85
84
  type = LEGACY_TYPE_TO_CE_TYPE[context[:type]]
86
85
  return nil unless type && source
86
+ ce_data = convert_data context[:service], data
87
+ content_type = "application/json; charset=#{charset}"
87
88
  CloudEvents::Event.new id: context[:id],
88
89
  source: source,
89
90
  type: type,
90
91
  spec_version: "1.0",
91
- data_content_type: "application/json",
92
- data: data,
92
+ data_content_type: content_type,
93
+ data: ce_data,
93
94
  subject: subject,
94
95
  time: context[:timestamp]
95
96
  end
@@ -104,6 +105,14 @@ module FunctionsFramework
104
105
  end
105
106
  end
106
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
+
107
116
  LEGACY_TYPE_TO_SERVICE = {
108
117
  %r{^providers/cloud\.firestore/} => "firestore.googleapis.com",
109
118
  %r{^providers/cloud\.pubsub/} => "pubsub.googleapis.com",
@@ -75,21 +75,6 @@ module FunctionsFramework
75
75
  self
76
76
  end
77
77
 
78
- ##
79
- # This is an obsolete interface that defines an event function taking two
80
- # arguments (data and context) rather than one.
81
- #
82
- # @deprecated Use {Registry#add_cloud_event} instead.
83
- #
84
- def add_event name, &block
85
- name = name.to_s
86
- synchronize do
87
- raise ::ArgumentError, "Function already defined: #{name}" if @functions.key? name
88
- @functions[name] = Function.new name, :event, &block
89
- end
90
- self
91
- end
92
-
93
78
  ##
94
79
  # Add a CloudEvent function to the registry.
95
80
  #
@@ -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}"
@@ -149,8 +149,12 @@ module FunctionsFramework
149
149
  ::Signal.trap "SIGINT" do
150
150
  Server.signal_enqueue "SIGINT", @config.logger, @server
151
151
  end
152
- ::Signal.trap "SIGHUP" do
153
- Server.signal_enqueue "SIGHUP", @config.logger, @server
152
+ begin
153
+ ::Signal.trap "SIGHUP" do
154
+ Server.signal_enqueue "SIGHUP", @config.logger, @server
155
+ end
156
+ rescue ::ArgumentError # rubocop:disable Lint/HandleExceptions
157
+ # Not available on all systems
154
158
  end
155
159
  @signals_installed = true
156
160
  end
@@ -384,10 +388,11 @@ module FunctionsFramework
384
388
  return notfound_response if excluded_path? env
385
389
  response =
386
390
  begin
387
- logger = env["rack.logger"] = @config.logger
391
+ logger = env["rack.logger"] ||= @config.logger
388
392
  request = ::Rack::Request.new env
389
393
  logger.info "FunctionsFramework: Handling HTTP #{request.request_method} request"
390
- @function.call request
394
+ calling_context = @function.new_call logger: logger
395
+ calling_context.call request
391
396
  rescue ::StandardError => e
392
397
  e
393
398
  end
@@ -406,7 +411,7 @@ module FunctionsFramework
406
411
 
407
412
  def call env
408
413
  return notfound_response if excluded_path? env
409
- logger = env["rack.logger"] = @config.logger
414
+ logger = env["rack.logger"] ||= @config.logger
410
415
  event = decode_event env
411
416
  response =
412
417
  case event
@@ -434,7 +439,8 @@ module FunctionsFramework
434
439
 
435
440
  def handle_cloud_event event, logger
436
441
  logger.info "FunctionsFramework: Handling CloudEvent"
437
- @function.call event
442
+ calling_context = @function.new_call logger: logger
443
+ calling_context.call event
438
444
  "ok"
439
445
  rescue ::StandardError => e
440
446
  e