functions_framework 0.2.0 → 0.4.1

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