Skip to content

Commit 71f0ea8

Browse files
authored
feat!: Decode returns opaque event objects when a formatter is not available (#58)
Signed-off-by: Daniel Azuma <[email protected]>
1 parent 1c7cae8 commit 71f0ea8

File tree

10 files changed

+358
-51
lines changed

10 files changed

+358
-51
lines changed

.rubocop.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ Metrics/BlockLength:
88
Exclude:
99
- "test/**/test_*.rb"
1010
Metrics/ClassLength:
11-
Max: 200
11+
Max: 250
1212
Metrics/ModuleLength:
13-
Max: 200
13+
Max: 250
1414
Naming/FileName:
1515
Exclude:
1616
- "examples/*/Gemfile"

lib/cloud_events/content_type.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class ContentType
2525
# specified. Defaults to `us-ascii`.
2626
#
2727
def initialize string, default_charset: nil
28-
@string = string
28+
@string = string.to_s
2929
@media_type = "text"
3030
@subtype_base = @subtype = "plain"
3131
@subtype_format = nil

lib/cloud_events/errors.rb

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,54 @@ class CloudEventsError < ::StandardError
88
end
99

1010
##
11-
# An error signaling that a protocol handler does not believe that a piece of
12-
# data is intended to be a CloudEvent.
11+
# An error raised when a protocol binding was asked to decode a CloudEvent
12+
# from input data, but does not believe that the data was intended to be a
13+
# CloudEvent. For example, the HttpBinding might raise this exception if
14+
# given a request that has neither the requisite headers for binary content
15+
# mode, nor an appropriate content-type for structured content mode.
1316
#
14-
class NotCloudEventError < ::StandardError
17+
class NotCloudEventError < CloudEventsError
1518
end
1619

1720
##
18-
# Errors indicating unsupported or incorrectly formatted HTTP content or
19-
# headers.
21+
# An error raised when a protocol binding was asked to decode a CloudEvent
22+
# from input data, and the data appears to be a CloudEvent, but was encoded
23+
# in a format that is not supported. Some protocol bindings can be configured
24+
# to return a {CloudEvents::Event::Opaque} object instead of raising this
25+
# error.
2026
#
21-
class HttpContentError < CloudEventsError
27+
class UnsupportedFormatError < CloudEventsError
2228
end
2329

2430
##
25-
# Errors indicating an unsupported or incorrect spec version.
31+
# An error raised when a protocol binding was asked to decode a CloudEvent
32+
# from input data, and the data appears to be intended as a CloudEvent, but
33+
# has unrecoverable format or syntax errors. This error _may_ have a `cause`
34+
# such as a `JSON::ParserError` with additional information.
35+
#
36+
class FormatSyntaxError < CloudEventsError
37+
end
38+
39+
##
40+
# An error raised when a `specversion` is set to a value not recognized or
41+
# supported by the SDK.
2642
#
2743
class SpecVersionError < CloudEventsError
2844
end
2945

3046
##
31-
# Errors related to CloudEvent attributes.
47+
# An error raised when a malformed CloudEvents attribute is encountered,
48+
# often because a required attribute is missing, or a value does not match
49+
# the attribute's type specification.
3250
#
3351
class AttributeError < CloudEventsError
3452
end
53+
54+
##
55+
# Alias of UnsupportedFormatError, for backward compatibility.
56+
#
57+
# @deprecated Will be removed in version 1.0.
58+
# @private
59+
#
60+
HttpContentError = UnsupportedFormatError
3561
end

lib/cloud_events/event.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
require "date"
44
require "uri"
55

6-
require "cloud_events/event/field_interpreter"
6+
require "cloud_events/event/opaque"
77
require "cloud_events/event/v0"
88
require "cloud_events/event/v1"
99

lib/cloud_events/event/opaque.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# frozen_string_literal: true
2+
3+
module CloudEvents
4+
module Event
5+
##
6+
# This object represents opaque event data that arrived in structured
7+
# content mode but was not in a recognized format. It may represent a
8+
# single event or a batch of events.
9+
#
10+
# The event data is retained in a form that can be reserialized (in a
11+
# structured cotent mode in the same format) but cannot otherwise be
12+
# inspected.
13+
#
14+
# This object is immutable, and Ractor-shareable on Ruby 3.
15+
#
16+
class Opaque
17+
##
18+
# Create an opaque object wrapping the given content and a content type.
19+
#
20+
# @param content [String] The opaque serialized event data.
21+
# @param content_type [CloudEvents::ContentType,nil] The content type,
22+
# or `nil` if there is no content type.
23+
# @param batch [boolean] Whether this represents a batch. If set to `nil`
24+
# or not provided, the value will be inferred from the content type
25+
# if possible, or otherwise set to `nil` indicating not known.
26+
#
27+
def initialize content, content_type, batch: nil
28+
@content = content.freeze
29+
@content_type = content_type
30+
if batch.nil? && content_type&.media_type == "application"
31+
case content_type.subtype_base
32+
when "cloudevents"
33+
batch = false
34+
when "cloudevents-batch"
35+
batch = true
36+
end
37+
end
38+
@batch = batch
39+
freeze
40+
end
41+
42+
##
43+
# The opaque serialized event data
44+
#
45+
# @return [String]
46+
#
47+
attr_reader :content
48+
49+
##
50+
# The content type, or `nil` if there is no content type.
51+
#
52+
# @return [CloudEvents::ContentType,nil]
53+
#
54+
attr_reader :content_type
55+
56+
##
57+
# Whether this represents a batch, or `nil` if not known.
58+
#
59+
# @return [boolean,nil]
60+
#
61+
def batch?
62+
@batch
63+
end
64+
65+
## @private
66+
def == other
67+
Opaque === other &&
68+
@content == other.content &&
69+
@content_type == other.content_type &&
70+
@batch == other.batch?
71+
end
72+
alias eql? ==
73+
74+
## @private
75+
def hash
76+
@content.hash ^ @content_type.hash ^ @batch.hash
77+
end
78+
end
79+
end
80+
end

lib/cloud_events/http_binding.rb

Lines changed: 70 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ def register_formatter_methods formatter,
129129
# the request.
130130
#
131131
# @param env [Hash] The Rack environment.
132+
# @param allow_opaque [boolean] If true, returns opaque event objects if
133+
# the input is not in a recognized format. If false, raises
134+
# {CloudEvents::UnsupportedFormatError} in that case. Default is false.
132135
# @param format_args [keywords] Extra args to pass to the formatter.
133136
# @return [CloudEvents::Event] if the request includes a single structured
134137
# or binary event.
@@ -137,38 +140,76 @@ def register_formatter_methods formatter,
137140
# @raise [CloudEvents::CloudEventsError] if an event could not be decoded
138141
# from the request.
139142
#
140-
def decode_rack_env env, **format_args
143+
def decode_rack_env env, allow_opaque: false, **format_args
141144
content_type_header = env["CONTENT_TYPE"]
142145
content_type = ContentType.new content_type_header if content_type_header
143146
if content_type&.media_type == "application"
144147
case content_type.subtype_base
145148
when "cloudevents"
146149
content = read_with_charset env["rack.input"], content_type.charset
147-
return decode_structured_content content, content_type, **format_args
150+
return decode_structured_content content, content_type, allow_opaque, **format_args
148151
when "cloudevents-batch"
149152
content = read_with_charset env["rack.input"], content_type.charset
150-
return decode_batched_content content, content_type, **format_args
153+
return decode_batched_content content, content_type, allow_opaque, **format_args
151154
end
152155
end
153156
decode_binary_content env, content_type
154157
end
155158

156159
##
157-
# Encode a single event in the given format.
160+
# Encode an event or batch of events into HTTP headers and body.
161+
#
162+
# You may provide an event, an array of events, or an opaque event. You may
163+
# also specify what content mode and format to use.
158164
#
159165
# The result is a two-element array where the first element is a headers
160166
# list (as defined in the Rack specification) and the second is a string
161-
# containing the HTTP body content. The headers list will contain only
162-
# a `Content-Type` header.
167+
# containing the HTTP body content. When using structured content mode, the
168+
# headers list will contain only a `Content-Type` header and the body will
169+
# contain the serialized event. When using binary mode, the header list
170+
# will contain the serialized event attributes and the body will contain
171+
# the serialized event data.
172+
#
173+
# @param event [CloudEvents::Event,Array<CloudEvents::Event>,CloudEvents::Event::Opaque]
174+
# The event, batch, or opaque event.
175+
# @param structured_format [boolean,String] If given, the data will be
176+
# encoded in structured content mode. You can pass a string to select
177+
# a format name, or pass `true` to use the default format. If set to
178+
# `false` (the default), the data will be encoded in binary mode.
179+
# @param format_args [keywords] Extra args to pass to the formatter.
180+
# @return [Array(headers,String)]
181+
#
182+
def encode_event event, structured_format: false, **format_args
183+
if event.is_a? Event::Opaque
184+
[{ "Content-Type" => event.content_type.to_s }, event.content]
185+
elsif !structured_format
186+
encode_binary_content event, **format_args
187+
elsif event.is_a? ::Array
188+
structured_format = default_batched_encoder if structured_format == true
189+
raise ArgumentError, "Format name not specified, and no default is set" unless structured_format
190+
encode_batched_content event, structured_format, **format_args
191+
elsif event.is_a? Event
192+
structured_format = default_structured_encoder if structured_format == true
193+
raise ArgumentError, "Format name not specified, and no default is set" unless structured_format
194+
encode_structured_content event, structured_format, **format_args
195+
else
196+
raise ArgumentError, "Unknown event type: #{event.class}"
197+
end
198+
end
199+
200+
##
201+
# Encode a single event in structured content mode in the given format.
202+
#
203+
# @deprecated Will be removed in vresion 1.0. Use encode_event instead.
204+
#
205+
# @private
163206
#
164207
# @param event [CloudEvents::Event] The event.
165-
# @param format [String] The format name. Optional.
208+
# @param format [String] The format name.
166209
# @param format_args [keywords] Extra args to pass to the formatter.
167210
# @return [Array(headers,String)]
168211
#
169-
def encode_structured_content event, format = nil, **format_args
170-
format ||= default_structured_encoder
171-
raise ArgumentError, "Format name not specified, and no default is set" unless format
212+
def encode_structured_content event, format, **format_args
172213
Array(@event_encoders[format]).reverse_each do |handler|
173214
result = handler.encode_event event, **format_args
174215
return [{ "Content-Type" => result[1].to_s }, result[0]] if result
@@ -177,21 +218,18 @@ def encode_structured_content event, format = nil, **format_args
177218
end
178219

179220
##
180-
# Encode a batch of events to content data in the given format.
221+
# Encode a batch of events in structured content mode in the given format.
181222
#
182-
# The result is a two-element array where the first element is a headers
183-
# list (as defined in the Rack specification) and the second is a string
184-
# containing the HTTP body content. The headers list will contain only
185-
# a `Content-Type` header.
223+
# @deprecated Will be removed in vresion 1.0. Use encode_event instead.
224+
#
225+
# @private
186226
#
187227
# @param events [Array<CloudEvents::Event>] The batch of events.
188-
# @param format [String] The format name. Optional.
228+
# @param format [String] The format name.
189229
# @param format_args [keywords] Extra args to pass to the formatter.
190230
# @return [Array(headers,String)]
191231
#
192-
def encode_batched_content events, format = nil, **format_args
193-
format ||= default_batched_encoder
194-
raise ArgumentError, "Format name not specified, and no default is set" unless format
232+
def encode_batched_content events, format, **format_args
195233
Array(@batch_encoders[format]).reverse_each do |handler|
196234
result = handler.encode_batch events, **format_args
197235
return [{ "Content-Type" => result[1].to_s }, result[0]] if result
@@ -200,16 +238,17 @@ def encode_batched_content events, format = nil, **format_args
200238
end
201239

202240
##
203-
# Encode an event to content and headers, in binary content mode.
241+
# Encode an event in binary content mode.
204242
#
205-
# The result is a two-element array where the first element is a headers
206-
# list (as defined in the Rack specification) and the second is a string
207-
# containing the HTTP body content.
243+
# @deprecated Will be removed in vresion 1.0. Use encode_event instead.
244+
#
245+
# @private
208246
#
209247
# @param event [CloudEvents::Event] The event.
248+
# @param format_args [keywords] Extra args to pass to the formatter.
210249
# @return [Array(headers,String)]
211250
#
212-
def encode_binary_content event
251+
def encode_binary_content event, **format_args
213252
headers = {}
214253
body = event.data
215254
content_type = event.data_content_type
@@ -218,7 +257,7 @@ def encode_binary_content event
218257
headers["CE-#{key}"] = percent_encode value
219258
end
220259
end
221-
body, content_type = encode_data body, content_type
260+
body, content_type = encode_data body, content_type, **format_args
222261
headers["Content-Type"] = content_type.to_s if content_type
223262
[headers, body]
224263
end
@@ -278,24 +317,26 @@ def add_named_formatter collection, formatter, name
278317
# Decode a single event from the given request body and content type in
279318
# structured mode.
280319
#
281-
def decode_structured_content input, content_type, **format_args
320+
def decode_structured_content input, content_type, allow_opaque, **format_args
282321
@event_decoders.reverse_each do |decoder|
283322
event = decoder.decode_event input, content_type, **format_args
284323
return event if event
285324
end
286-
raise HttpContentError, "Unknown cloudevents content type: #{content_type}"
325+
return Event::Opaque.new input, content_type, batch: false if allow_opaque
326+
raise UnsupportedFormatError, "Unknown cloudevents content type: #{content_type}"
287327
end
288328

289329
##
290330
# Decode a batch of events from the given request body and content type in
291331
# batched structured mode.
292332
#
293-
def decode_batched_content input, content_type, **format_args
333+
def decode_batched_content input, content_type, allow_opaque, **format_args
294334
@batch_decoders.reverse_each do |decoder|
295335
events = decoder.decode_batch input, content_type, **format_args
296336
return events if events
297337
end
298-
raise HttpContentError, "Unknown cloudevents content type: #{content_type}"
338+
return Event::Opaque.new input, content_type, batch: true if allow_opaque
339+
raise UnsupportedFormatError, "Unknown cloudevents content type: #{content_type}"
299340
end
300341

301342
##

lib/cloud_events/json_format.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def decode_event input, content_type, **_other_kwargs
2929
return nil unless content_type&.subtype_format == "json"
3030
structure = ::JSON.parse input
3131
decode_hash_structure structure
32+
rescue ::JSON::JSONError
33+
raise CloudEvents::FormatSyntaxError, "JSON syntax error"
3234
end
3335

3436
##
@@ -67,6 +69,8 @@ def decode_batch input, content_type, **_other_kwargs
6769
structure_array.map do |structure|
6870
decode_hash_structure structure
6971
end
72+
rescue ::JSON::JSONError
73+
raise CloudEvents::FormatSyntaxError, "JSON syntax error"
7074
end
7175

7276
##
@@ -104,6 +108,8 @@ def encode_batch events, sort: false, **_other_kwargs
104108
def decode_data data, content_type, **_other_kwargs
105109
return nil unless content_type&.subtype_base == "json" || content_type&.subtype_format == "json"
106110
[::JSON.parse(data), content_type]
111+
rescue ::JSON::JSONError
112+
raise CloudEvents::FormatSyntaxError, "JSON syntax error"
107113
end
108114

109115
##
@@ -193,7 +199,7 @@ def charset_of str
193199
def decode_hash_structure_v0 structure
194200
data = structure["data"]
195201
if data.is_a? ::String
196-
content_type = ContentType.new structure["datacontenttype"] rescue nil
202+
content_type = ContentType.new structure["datacontenttype"]
197203
if content_type&.subtype_base == "json" || content_type&.subtype_format == "json"
198204
structure = structure.dup
199205
structure["data"] = ::JSON.parse data rescue data

0 commit comments

Comments
 (0)