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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +2 -2
- data/docs/deploying-functions.md +21 -2
- data/docs/overview.md +2 -2
- data/docs/running-a-functions-server.md +1 -1
- data/docs/writing-functions.md +37 -22
- data/lib/functions_framework.rb +0 -11
- data/lib/functions_framework/cli.rb +1 -1
- data/lib/functions_framework/cloud_events.rb +4 -2
- data/lib/functions_framework/cloud_events/content_type.rb +114 -31
- data/lib/functions_framework/cloud_events/errors.rb +1 -1
- data/lib/functions_framework/cloud_events/event.rb +11 -6
- data/lib/functions_framework/cloud_events/event/field_interpreter.rb +150 -0
- data/lib/functions_framework/cloud_events/event/v0.rb +236 -0
- data/lib/functions_framework/cloud_events/event/v1.rb +21 -161
- data/lib/functions_framework/cloud_events/http_binding.rb +44 -4
- data/lib/functions_framework/cloud_events/json_format.rb +64 -13
- data/lib/functions_framework/function.rb +80 -24
- data/lib/functions_framework/legacy_event_converter.rb +30 -21
- data/lib/functions_framework/registry.rb +0 -15
- data/lib/functions_framework/server.rb +13 -7
- data/lib/functions_framework/testing.rb +40 -14
- data/lib/functions_framework/version.rb +1 -1
- metadata +9 -101
@@ -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
|
-
#
|
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.
|
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
|
-
#
|
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
|
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
|
-
|
88
|
-
|
89
|
-
|
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
|
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
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
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
|
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
|
-
# `:
|
28
|
-
# @param
|
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
|
-
@
|
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
|
-
#
|
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
|
-
|
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
|
-
#
|
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
|
-
#
|
62
|
-
# @return [Object]
|
102
|
+
# An object of this class is `self` while a function block is running.
|
63
103
|
#
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
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.
|
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
|
-
|
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
|
54
|
-
|
55
|
-
|
56
|
-
timestamp = raw_context["timestamp"]
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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
|
63
|
+
def analyze_resource raw_resource
|
64
|
+
service = resource = nil
|
66
65
|
case raw_resource
|
67
66
|
when ::Hash
|
68
|
-
|
67
|
+
service = raw_resource["service"]
|
68
|
+
resource = raw_resource["name"]
|
69
69
|
when ::String
|
70
|
-
|
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:
|
92
|
-
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 :
|
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
|
-
|
153
|
-
|
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"]
|
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.
|
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"]
|
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.
|
442
|
+
calling_context = @function.new_call logger: logger
|
443
|
+
calling_context.call event
|
438
444
|
"ok"
|
439
445
|
rescue ::StandardError => e
|
440
446
|
e
|