cloud_events 0.4.0 → 0.5.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.
@@ -15,14 +15,21 @@ module CloudEvents
15
15
  #
16
16
  class HttpBinding
17
17
  ##
18
- # Returns a default binding, with JSON supported.
18
+ # The name of the JSON decoder/encoder
19
+ # @return [String]
20
+ #
21
+ JSON_FORMAT = "json"
22
+
23
+ ##
24
+ # Returns a default HTTP binding, including support for JSON format.
25
+ #
26
+ # @return [HttpBinding]
19
27
  #
20
28
  def self.default
21
29
  @default ||= begin
22
30
  http_binding = new
23
- json_format = JsonFormat.new
24
- http_binding.register_structured_formatter "json", json_format
25
- http_binding.register_batched_formatter "json", json_format
31
+ http_binding.register_formatter JsonFormat.new, JSON_FORMAT
32
+ http_binding.default_encoder_name = JSON_FORMAT
26
33
  http_binding
27
34
  end
28
35
  end
@@ -31,230 +38,278 @@ module CloudEvents
31
38
  # Create an empty HTTP binding.
32
39
  #
33
40
  def initialize
34
- @structured_formatters = {}
35
- @batched_formatters = {}
41
+ @event_decoders = []
42
+ @event_encoders = {}
43
+ @data_decoders = [DefaultDataFormat]
44
+ @data_encoders = [DefaultDataFormat]
45
+ @default_encoder_name = nil
36
46
  end
37
47
 
38
48
  ##
39
- # Register a formatter for the given type.
49
+ # Register a formatter for all operations it supports, based on which
50
+ # methods are implemented by the formatter object. See
51
+ # {CloudEvents::Format} for a list of possible methods.
40
52
  #
41
- # A formatter must respond to the methods `#encode` and `#decode`. See
42
- # {CloudEvents::JsonFormat} for an example.
43
- #
44
- # @param type [String] The subtype format that should be handled by
45
- # this formatter.
46
- # @param formatter [Object] The formatter object.
53
+ # @param formatter [Object] The formatter
54
+ # @param name [String] The encoder name under which this formatter will
55
+ # register its encode operations. Optional. If not specified, any event
56
+ # encoder will _not_ be registered.
47
57
  # @return [self]
48
58
  #
49
- def register_structured_formatter type, formatter
50
- formatters = @structured_formatters[type.to_s.strip.downcase] ||= []
51
- formatters << formatter unless formatters.include? formatter
59
+ def register_formatter formatter, name = nil
60
+ name = name.to_s.strip.downcase if name
61
+ decode_event = formatter.respond_to? :decode_event
62
+ encode_event = name if formatter.respond_to? :encode_event
63
+ decode_data = formatter.respond_to? :decode_data
64
+ encode_data = formatter.respond_to? :encode_data
65
+ register_formatter_methods formatter,
66
+ decode_event: decode_event,
67
+ encode_event: encode_event,
68
+ decode_data: decode_data,
69
+ encode_data: encode_data
52
70
  self
53
71
  end
54
72
 
55
73
  ##
56
- # Register a batch formatter for the given type.
57
- #
58
- # A batch formatter must respond to the methods `#encode_batch` and
59
- # `#decode_batch`. See {CloudEvents::JsonFormat} for an example.
74
+ # Registers the given formatter for the given operations. Some arguments
75
+ # are activated by passing `true`, whereas those that rely on a format name
76
+ # are activated by passing in a name string.
60
77
  #
61
- # @param type [String] The subtype format that should be handled by
62
- # this formatter.
63
- # @param formatter [Object] The formatter object.
78
+ # @param formatter [Object] The formatter
79
+ # @param decode_event [boolean] If true, register the formatter's
80
+ # {CloudEvents::Format#decode_event} method.
81
+ # @param encode_event [String] If set to a string, use the formatter's
82
+ # {CloudEvents::Format#encode_event} method when that name is requested.
83
+ # @param decode_data [boolean] If true, register the formatter's
84
+ # {CloudEvents::Format#decode_data} method.
85
+ # @param encode_data [boolean] If true, register the formatter's
86
+ # {CloudEvents::Format#encode_data} method.
64
87
  # @return [self]
65
88
  #
66
- def register_batched_formatter type, formatter
67
- formatters = @batched_formatters[type.to_s.strip.downcase] ||= []
68
- formatters << formatter unless formatters.include? formatter
89
+ def register_formatter_methods formatter,
90
+ decode_event: false,
91
+ encode_event: nil,
92
+ decode_data: false,
93
+ encode_data: false
94
+ @event_decoders << formatter if decode_event
95
+ if encode_event
96
+ formatters = @event_encoders[encode_event] ||= []
97
+ formatters << formatter unless formatters.include? formatter
98
+ end
99
+ @data_decoders << formatter if decode_data
100
+ @data_encoders << formatter if encode_data
69
101
  self
70
102
  end
71
103
 
104
+ ##
105
+ # The name of the encoder to use if none is specified
106
+ # @return [String,nil]
107
+ #
108
+ attr_accessor :default_encoder_name
109
+
110
+ ##
111
+ # Analyze a Rack environment hash and determine whether it is _probably_
112
+ # a CloudEvent. This is done by examining headers only, and does not read
113
+ # or parse the request body. The result is a best guess: false negatives or
114
+ # false positives are possible for edge cases, but the logic should
115
+ # generally detect canonically-formatted events.
116
+ #
117
+ # @param env [Hash] The Rack environment.
118
+ # @return [boolean] Whether the request is likely a CloudEvent.
119
+ #
120
+ def probable_event? env
121
+ return true if env["HTTP_CE_SPECVERSION"]
122
+ content_type = ContentType.new env["CONTENT_TYPE"].to_s
123
+ content_type.media_type == "application" &&
124
+ ["cloudevents", "cloudevents-batch"].include?(content_type.subtype_base)
125
+ end
126
+
72
127
  ##
73
128
  # Decode an event from the given Rack environment hash. Following the
74
129
  # CloudEvents spec, this chooses a handler based on the Content-Type of
75
130
  # the request.
76
131
  #
132
+ # Note that this method will read the body (i.e. `rack.input`) stream.
133
+ # If you need to access the body after calling this method, you will need
134
+ # to rewind the stream. To determine whether the request is a CloudEvent
135
+ # without reading the body, use {#probable_event?}.
136
+ #
77
137
  # @param env [Hash] The Rack environment.
138
+ # @param allow_opaque [boolean] If true, returns opaque event objects if
139
+ # the input is not in a recognized format. If false, raises
140
+ # {CloudEvents::UnsupportedFormatError} in that case. Default is false.
78
141
  # @param format_args [keywords] Extra args to pass to the formatter.
79
142
  # @return [CloudEvents::Event] if the request includes a single structured
80
143
  # or binary event.
81
144
  # @return [Array<CloudEvents::Event>] if the request includes a batch of
82
145
  # structured events.
83
- # @return [nil] if the request was not recognized as a CloudEvent.
146
+ # @raise [CloudEvents::CloudEventsError] if an event could not be decoded
147
+ # from the request.
84
148
  #
85
- def decode_rack_env env, **format_args
86
- content_type_header = env["CONTENT_TYPE"]
87
- content_type = ContentType.new content_type_header if content_type_header
88
- input = env["rack.input"]
89
- if input && content_type&.media_type == "application"
90
- case content_type.subtype_base
91
- when "cloudevents"
92
- content = read_with_charset input, content_type.charset
93
- return decode_structured_content content, content_type.subtype_format, **format_args
94
- when "cloudevents-batch"
95
- content = read_with_charset input, content_type.charset
96
- return decode_batched_content content, content_type.subtype_format, **format_args
97
- end
149
+ def decode_event env, allow_opaque: false, **format_args
150
+ content_type_string = env["CONTENT_TYPE"]
151
+ content_type = ContentType.new content_type_string if content_type_string
152
+ content = read_with_charset env["rack.input"], content_type&.charset
153
+ result = decode_binary_content(content, content_type, env) ||
154
+ decode_structured_content(content, content_type, allow_opaque, **format_args)
155
+ if result.nil?
156
+ content_type_string = content_type_string ? content_type_string.inspect : "not present"
157
+ raise NotCloudEventError, "Content-Type is #{content_type_string}, and CE-SpecVersion is not present"
98
158
  end
99
- decode_binary_content env, content_type
159
+ result
100
160
  end
101
161
 
102
162
  ##
103
- # Decode a single event from the given content data. This should be
104
- # passed the request body, if the Content-Type is of the form
105
- # `application/cloudevents+format`.
163
+ # Encode an event or batch of events into HTTP headers and body.
106
164
  #
107
- # @param input [String] The string content.
108
- # @param format [String] The format code (e.g. "json").
109
- # @param format_args [keywords] Extra args to pass to the formatter.
110
- # @return [CloudEvents::Event]
165
+ # You may provide an event, an array of events, or an opaque event. You may
166
+ # also specify what content mode and format to use.
111
167
  #
112
- def decode_structured_content input, format, **format_args
113
- handlers = @structured_formatters[format] || []
114
- handlers.reverse_each do |handler|
115
- event = handler.decode input, **format_args
116
- return event if event
117
- end
118
- raise HttpContentError, "Unknown cloudevents format: #{format.inspect}"
119
- end
120
-
121
- ##
122
- # Decode a batch of events from the given content data. This should be
123
- # passed the request body, if the Content-Type is of the form
124
- # `application/cloudevents-batch+format`.
168
+ # The result is a two-element array where the first element is a headers
169
+ # list (as defined in the Rack specification) and the second is a string
170
+ # containing the HTTP body content. When using structured content mode, the
171
+ # headers list will contain only a `Content-Type` header and the body will
172
+ # contain the serialized event. When using binary mode, the header list
173
+ # will contain the serialized event attributes and the body will contain
174
+ # the serialized event data.
125
175
  #
126
- # @param input [String] The string content.
127
- # @param format [String] The format code (e.g. "json").
176
+ # @param event [CloudEvents::Event,Array<CloudEvents::Event>,CloudEvents::Event::Opaque]
177
+ # The event, batch, or opaque event.
178
+ # @param structured_format [boolean,String] If given, the data will be
179
+ # encoded in structured content mode. You can pass a string to select
180
+ # a format name, or pass `true` to use the default format. If set to
181
+ # `false` (the default), the data will be encoded in binary mode.
128
182
  # @param format_args [keywords] Extra args to pass to the formatter.
129
- # @return [Array<CloudEvents::Event>]
183
+ # @return [Array(headers,String)]
130
184
  #
131
- def decode_batched_content input, format, **format_args
132
- handlers = @batched_formatters[format] || []
133
- handlers.reverse_each do |handler|
134
- events = handler.decode_batch input, **format_args
135
- return events if events
185
+ def encode_event event, structured_format: false, **format_args
186
+ if event.is_a? Event::Opaque
187
+ [{ "Content-Type" => event.content_type.to_s }, event.content]
188
+ elsif !structured_format
189
+ if event.is_a? ::Array
190
+ raise ::ArgumentError, "Encoding a batch requires structured_format"
191
+ end
192
+ encode_binary_content event, legacy_data_encode: false, **format_args
193
+ else
194
+ structured_format = default_encoder_name if structured_format == true
195
+ raise ::ArgumentError, "Format name not specified, and no default is set" unless structured_format
196
+ case event
197
+ when ::Array
198
+ encode_batched_content event, structured_format, **format_args
199
+ when Event
200
+ encode_structured_content event, structured_format, **format_args
201
+ else
202
+ raise ::ArgumentError, "Unknown event type: #{event.class}"
203
+ end
136
204
  end
137
- raise HttpContentError, "Unknown cloudevents batch format: #{format.inspect}"
138
205
  end
139
206
 
140
207
  ##
141
- # Decode an event from the given Rack environment in binary content mode.
208
+ # Decode an event from the given Rack environment hash. Following the
209
+ # CloudEvents spec, this chooses a handler based on the Content-Type of
210
+ # the request.
142
211
  #
143
- # @param env [Hash] Rack environment hash.
144
- # @param content_type [CloudEvents::ContentType] the content type from the
145
- # Rack environment.
146
- # @return [CloudEvents::Event] if a CloudEvent could be decoded from the
147
- # Rack environment.
148
- # @return [nil] if the Rack environment does not indicate a CloudEvent
212
+ # @deprecated Will be removed in version 1.0. Use {#decode_event} instead.
149
213
  #
150
- def decode_binary_content env, content_type
151
- spec_version = env["HTTP_CE_SPECVERSION"]
152
- return nil if spec_version.nil?
153
- raise SpecVersionError, "Unrecognized specversion: #{spec_version}" unless spec_version == "1.0"
154
- input = env["rack.input"]
155
- data = read_with_charset input, content_type&.charset if input
156
- attributes = { "spec_version" => spec_version, "data" => data }
157
- attributes["data_content_type"] = content_type if content_type
158
- omit_names = ["specversion", "spec_version", "data", "datacontenttype", "data_content_type"]
159
- env.each do |key, value|
160
- match = /^HTTP_CE_(\w+)$/.match key
161
- next unless match
162
- attr_name = match[1].downcase
163
- attributes[attr_name] = percent_decode value unless omit_names.include? attr_name
164
- end
165
- Event.create spec_version: spec_version, attributes: attributes
214
+ # @param env [Hash] The Rack environment.
215
+ # @param format_args [keywords] Extra args to pass to the formatter.
216
+ # @return [CloudEvents::Event] if the request includes a single structured
217
+ # or binary event.
218
+ # @return [Array<CloudEvents::Event>] if the request includes a batch of
219
+ # structured events.
220
+ # @return [nil] if the request does not appear to be a CloudEvent.
221
+ # @raise [CloudEvents::CloudEventsError] if the request appears to be a
222
+ # CloudEvent but decoding failed.
223
+ #
224
+ def decode_rack_env env, **format_args
225
+ content_type_string = env["CONTENT_TYPE"]
226
+ content_type = ContentType.new content_type_string if content_type_string
227
+ content = read_with_charset env["rack.input"], content_type&.charset
228
+ env["rack.input"].rewind rescue nil
229
+ decode_binary_content(content, content_type, env, legacy_data_decode: true) ||
230
+ decode_structured_content(content, content_type, false, **format_args)
166
231
  end
167
232
 
168
233
  ##
169
- # Encode a single event to content data in the given format.
234
+ # Encode a single event in structured content mode in the given format.
170
235
  #
171
- # The result is a two-element array where the first element is a headers
172
- # list (as defined in the Rack specification) and the second is a string
173
- # containing the HTTP body content. The headers list will contain only
174
- # one header, a `Content-Type` whose value is of the form
175
- # `application/cloudevents+format`.
236
+ # @deprecated Will be removed in version 1.0. Use {#encode_event} instead.
176
237
  #
177
238
  # @param event [CloudEvents::Event] The event.
178
- # @param format [String] The format code (e.g. "json")
239
+ # @param format_name [String] The format name.
179
240
  # @param format_args [keywords] Extra args to pass to the formatter.
180
241
  # @return [Array(headers,String)]
181
242
  #
182
- def encode_structured_content event, format, **format_args
183
- handlers = @structured_formatters[format] || []
184
- handlers.reverse_each do |handler|
185
- content = handler.encode event, **format_args
186
- if content
187
- content_type = "application/cloudevents+#{format}; charset=#{charset_of content}"
188
- return [{ "Content-Type" => content_type }, content]
243
+ def encode_structured_content event, format_name, **format_args
244
+ Array(@event_encoders[format_name]).reverse_each do |handler|
245
+ result = handler.encode_event event: event, **format_args
246
+ if result&.key?(:content) && result&.key?(:content_type)
247
+ return [{ "Content-Type" => result[:content_type].to_s }, result[:content]]
189
248
  end
190
249
  end
191
- raise HttpContentError, "Unknown cloudevents format: #{format.inspect}"
250
+ raise ::ArgumentError, "Unknown format name: #{format_name.inspect}"
192
251
  end
193
252
 
194
253
  ##
195
- # Encode a batch of events to content data in the given format.
254
+ # Encode a batch of events in structured content mode in the given format.
196
255
  #
197
- # The result is a two-element array where the first element is a headers
198
- # list (as defined in the Rack specification) and the second is a string
199
- # containing the HTTP body content. The headers list will contain only
200
- # one header, a `Content-Type` whose value is of the form
201
- # `application/cloudevents-batch+format`.
256
+ # @deprecated Will be removed in version 1.0. Use {#encode_event} instead.
202
257
  #
203
- # @param events [Array<CloudEvents::Event>] The batch of events.
204
- # @param format [String] The format code (e.g. "json").
258
+ # @param event_batch [Array<CloudEvents::Event>] The batch of events.
259
+ # @param format_name [String] The format name.
205
260
  # @param format_args [keywords] Extra args to pass to the formatter.
206
261
  # @return [Array(headers,String)]
207
262
  #
208
- def encode_batched_content events, format, **format_args
209
- handlers = @batched_formatters[format] || []
210
- handlers.reverse_each do |handler|
211
- content = handler.encode_batch events, **format_args
212
- if content
213
- content_type = "application/cloudevents-batch+#{format}; charset=#{charset_of content}"
214
- return [{ "Content-Type" => content_type }, content]
263
+ def encode_batched_content event_batch, format_name, **format_args
264
+ Array(@event_encoders[format_name]).reverse_each do |handler|
265
+ result = handler.encode_event event_batch: event_batch, **format_args
266
+ if result&.key?(:content) && result&.key?(:content_type)
267
+ return [{ "Content-Type" => result[:content_type].to_s }, result[:content]]
215
268
  end
216
269
  end
217
- raise HttpContentError, "Unknown cloudevents format: #{format.inspect}"
270
+ raise ::ArgumentError, "Unknown format name: #{format_name.inspect}"
218
271
  end
219
272
 
220
273
  ##
221
- # Encode an event to content and headers, in binary content mode.
274
+ # Encode an event in binary content mode.
222
275
  #
223
- # The result is a two-element array where the first element is a headers
224
- # list (as defined in the Rack specification) and the second is a string
225
- # containing the HTTP body content.
276
+ # @deprecated Will be removed in version 1.0. Use {#encode_event} instead.
226
277
  #
227
278
  # @param event [CloudEvents::Event] The event.
279
+ # @param format_args [keywords] Extra args to pass to the formatter.
228
280
  # @return [Array(headers,String)]
229
281
  #
230
- def encode_binary_content event
282
+ def encode_binary_content event, legacy_data_encode: true, **format_args
231
283
  headers = {}
232
- body = nil
233
284
  event.to_h.each do |key, value|
234
- case key
235
- when "data"
236
- body = value
237
- when "datacontenttype"
238
- headers["Content-Type"] = value
239
- else
285
+ unless ["data", "datacontenttype"].include? key
240
286
  headers["CE-#{key}"] = percent_encode value
241
287
  end
242
288
  end
243
- case body
244
- when ::String
245
- headers["Content-Type"] ||= string_content_type body
246
- when nil
247
- headers.delete "Content-Type"
289
+ if legacy_data_encode
290
+ body = event.data
291
+ content_type = event.data_content_type&.to_s
292
+ case body
293
+ when ::String
294
+ content_type ||= string_content_type body
295
+ when nil
296
+ content_type = nil
297
+ else
298
+ body = ::JSON.dump body
299
+ content_type ||= "application/json; charset=#{body.encoding.name.downcase}"
300
+ end
248
301
  else
249
- body = ::JSON.dump body
250
- headers["Content-Type"] ||= "application/json; charset=#{body.encoding.name.downcase}"
302
+ body, content_type = encode_data event.spec_version, event.data, event.data_content_type, **format_args
251
303
  end
304
+ headers["Content-Type"] = content_type.to_s if content_type
252
305
  [headers, body]
253
306
  end
254
307
 
255
308
  ##
256
309
  # Decode a percent-encoded string to a UTF-8 string.
257
310
  #
311
+ # @private
312
+ #
258
313
  # @param str [String] Incoming ascii string from an HTTP header, with one
259
314
  # cycle of percent-encoding.
260
315
  # @return [String] Resulting decoded string in UTF-8.
@@ -270,6 +325,8 @@ module CloudEvents
270
325
  # non-printing and non-ascii characters to result in an ASCII string
271
326
  # suitable for setting as an HTTP header value.
272
327
  #
328
+ # @private
329
+ #
273
330
  # @param str [String] Incoming arbitrary string that can be represented
274
331
  # in UTF-8.
275
332
  # @return [String] Resulting encoded string in ASCII.
@@ -293,7 +350,87 @@ module CloudEvents
293
350
 
294
351
  private
295
352
 
353
+ def add_named_formatter collection, formatter, name
354
+ return unless name
355
+ formatters = collection[name] ||= []
356
+ formatters << formatter unless formatters.include? formatter
357
+ end
358
+
359
+ ##
360
+ # Decode a single event from the given request body and content type in
361
+ # structured mode.
362
+ #
363
+ def decode_structured_content content, content_type, allow_opaque, **format_args
364
+ @event_decoders.reverse_each do |decoder|
365
+ result = decoder.decode_event content: content, content_type: content_type, **format_args
366
+ event = result[:event] || result[:event_batch] if result
367
+ return event if event
368
+ end
369
+ if content_type&.media_type == "application" &&
370
+ ["cloudevents", "cloudevents-batch"].include?(content_type.subtype_base)
371
+ return Event::Opaque.new content, content_type if allow_opaque
372
+ raise UnsupportedFormatError, "Unknown cloudevents content type: #{content_type}"
373
+ end
374
+ nil
375
+ end
376
+
377
+ ##
378
+ # Decode an event from the given Rack environment in binary content mode.
379
+ #
380
+ # TODO: legacy_data_decode is deprecated and can be removed when
381
+ # decode_rack_env is removed.
382
+ #
383
+ def decode_binary_content content, content_type, env, legacy_data_decode: false
384
+ spec_version = env["HTTP_CE_SPECVERSION"]
385
+ return nil unless spec_version
386
+ unless spec_version =~ /^0\.3|1(\.|$)/
387
+ raise SpecVersionError, "Unrecognized specversion: #{spec_version}"
388
+ end
389
+ if legacy_data_decode
390
+ data = content
391
+ else
392
+ data, content_type = decode_data spec_version, content, content_type
393
+ end
394
+ attributes = { "spec_version" => spec_version, "data" => data }
395
+ attributes["data_content_type"] = content_type if content_type
396
+ omit_names = ["specversion", "spec_version", "data", "datacontenttype", "data_content_type"]
397
+ env.each do |key, value|
398
+ match = /^HTTP_CE_(\w+)$/.match key
399
+ next unless match
400
+ attr_name = match[1].downcase
401
+ attributes[attr_name] = percent_decode value unless omit_names.include? attr_name
402
+ end
403
+ Event.create spec_version: spec_version, attributes: attributes
404
+ end
405
+
406
+ def decode_data spec_version, content, content_type, **format_args
407
+ @data_decoders.reverse_each do |handler|
408
+ result = handler.decode_data spec_version: spec_version,
409
+ content: content,
410
+ content_type: content_type,
411
+ **format_args
412
+ if result&.key?(:data) && result&.key?(:content_type)
413
+ return [result[:data], result[:content_type]]
414
+ end
415
+ end
416
+ raise "Should not get here"
417
+ end
418
+
419
+ def encode_data spec_version, data_obj, content_type, **format_args
420
+ @data_encoders.reverse_each do |handler|
421
+ result = handler.encode_data spec_version: spec_version,
422
+ data: data_obj,
423
+ content_type: content_type,
424
+ **format_args
425
+ if result&.key?(:content) && result&.key?(:content_type)
426
+ return [result[:content], result[:content_type]]
427
+ end
428
+ end
429
+ raise "Should not get here"
430
+ end
431
+
296
432
  def read_with_charset io, charset
433
+ return nil if io.nil?
297
434
  str = io.read
298
435
  if charset
299
436
  begin
@@ -306,20 +443,23 @@ module CloudEvents
306
443
  str
307
444
  end
308
445
 
309
- def string_content_type str
310
- if str.encoding == ::Encoding::ASCII_8BIT
311
- "application/octet-stream"
312
- else
313
- "text/plain; charset=#{str.encoding.name.downcase}"
446
+ # @private
447
+ module DefaultDataFormat
448
+ # @private
449
+ def self.decode_data content: nil, content_type: nil, **_extra_kwargs
450
+ { data: content, content_type: content_type }
314
451
  end
315
- end
316
452
 
317
- def charset_of str
318
- encoding = str.encoding
319
- if encoding == ::Encoding::ASCII_8BIT
320
- "binary"
321
- else
322
- encoding.name.downcase
453
+ # @private
454
+ def self.encode_data data: nil, content_type: nil, **_extra_kwargs
455
+ content = data.to_s
456
+ content_type ||=
457
+ if content.encoding == ::Encoding::ASCII_8BIT
458
+ "application/octet-stream"
459
+ else
460
+ "text/plain; charset=#{content.encoding.name.downcase}"
461
+ end
462
+ { content: content, content_type: content_type }
323
463
  end
324
464
  end
325
465
  end