safrano 0.4.3 → 0.4.4
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/lib/core_ext/Dir/iter.rb +18 -0
- data/lib/core_ext/Hash/transform.rb +21 -0
- data/lib/core_ext/Integer/edm.rb +13 -0
- data/lib/core_ext/REXML/Document/output.rb +16 -0
- data/lib/core_ext/String/convert.rb +25 -0
- data/lib/core_ext/String/edm.rb +13 -0
- data/lib/core_ext/dir.rb +3 -0
- data/lib/core_ext/hash.rb +3 -0
- data/lib/core_ext/integer.rb +3 -0
- data/lib/core_ext/rexml.rb +3 -0
- data/lib/core_ext/string.rb +5 -0
- data/lib/odata/attribute.rb +6 -2
- data/lib/odata/batch.rb +9 -7
- data/lib/odata/collection.rb +136 -642
- data/lib/odata/collection_filter.rb +16 -40
- data/lib/odata/collection_media.rb +56 -37
- data/lib/odata/collection_order.rb +5 -2
- data/lib/odata/common_logger.rb +2 -0
- data/lib/odata/complex_type.rb +152 -0
- data/lib/odata/edm/primitive_types.rb +184 -0
- data/lib/odata/entity.rb +53 -117
- data/lib/odata/error.rb +142 -37
- data/lib/odata/expand.rb +20 -17
- data/lib/odata/filter/base.rb +4 -1
- data/lib/odata/filter/error.rb +43 -27
- data/lib/odata/filter/parse.rb +33 -25
- data/lib/odata/filter/sequel.rb +97 -56
- data/lib/odata/filter/sequel_function_adapter.rb +50 -49
- data/lib/odata/filter/token.rb +10 -10
- data/lib/odata/filter/tree.rb +75 -41
- data/lib/odata/function_import.rb +166 -0
- data/lib/odata/model_ext.rb +618 -0
- data/lib/odata/navigation_attribute.rb +9 -24
- data/lib/odata/relations.rb +5 -5
- data/lib/odata/select.rb +17 -5
- data/lib/odata/transition.rb +71 -0
- data/lib/odata/url_parameters.rb +100 -24
- data/lib/odata/walker.rb +15 -7
- data/lib/safrano.rb +18 -38
- data/lib/safrano/contract.rb +143 -0
- data/lib/safrano/core.rb +12 -94
- data/lib/safrano/core_ext.rb +13 -0
- data/lib/safrano/deprecation.rb +73 -0
- data/lib/safrano/multipart.rb +25 -20
- data/lib/safrano/rack_app.rb +61 -62
- data/lib/safrano/{odata_rack_builder.rb → rack_builder.rb} +18 -1
- data/lib/safrano/request.rb +95 -37
- data/lib/safrano/response.rb +4 -2
- data/lib/safrano/sequel_join_by_paths.rb +2 -2
- data/lib/safrano/service.rb +132 -94
- data/lib/safrano/version.rb +3 -1
- data/lib/sequel/plugins/join_by_paths.rb +6 -19
- metadata +24 -5
data/lib/odata/error.rb
CHANGED
@@ -1,21 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'json'
|
2
4
|
require 'rexml/document'
|
3
|
-
|
5
|
+
require_relative '../safrano/contract'
|
4
6
|
|
5
7
|
# Error handling
|
6
|
-
module
|
8
|
+
module Safrano
|
7
9
|
# for errors occurring in API (publishing) usage --> Exceptions
|
8
10
|
module API
|
9
11
|
# when published class is not a Sequel Model
|
10
12
|
class ModelNameError < NameError
|
11
13
|
def initialize(name)
|
12
|
-
super("class #{name} is not a Sequel Model"
|
14
|
+
super("class #{name} is not a Sequel Model")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# when published complex type is not a Complex Type
|
19
|
+
class ComplexTypeNameError < NameError
|
20
|
+
def initialize(name)
|
21
|
+
super("class #{name} is not a ComplexType")
|
13
22
|
end
|
14
23
|
end
|
24
|
+
|
15
25
|
# when published class as media does not have the mandatory media fields
|
16
26
|
class MediaModelError < NameError
|
17
27
|
def initialize(name)
|
18
|
-
super("Model #{name} does not have the mandatory media attributes content_type/media_src"
|
28
|
+
super("Model #{name} does not have the mandatory media attributes content_type/media_src")
|
19
29
|
end
|
20
30
|
end
|
21
31
|
|
@@ -31,43 +41,88 @@ module OData
|
|
31
41
|
|
32
42
|
# base module for HTTP errors, when used as a Error Class
|
33
43
|
module ErrorClass
|
44
|
+
include ::Safrano::Contract::Invalid
|
45
|
+
def http_code
|
46
|
+
const_get(:HTTP_CODE)
|
47
|
+
end
|
48
|
+
EMPTYH = {}.freeze
|
34
49
|
def odata_get(req)
|
50
|
+
message = (m = @msg.to_s).empty? ? to_s : m
|
35
51
|
if req.accept?(APPJSON)
|
36
|
-
|
37
|
-
|
52
|
+
# json is default content type so we dont need to specify it here again
|
53
|
+
[self.http_code, EMPTY_HASH,
|
54
|
+
{ 'odata.error' => { 'code' => "#{http_code}",
|
55
|
+
'type' => to_s,
|
56
|
+
'message' => message } }.to_json]
|
38
57
|
else
|
39
|
-
[
|
58
|
+
[self.http_code, CT_TEXT, message]
|
40
59
|
end
|
41
60
|
end
|
42
61
|
end
|
43
62
|
|
44
63
|
# base module for HTTP errors, when used as an Error instance
|
45
64
|
module ErrorInstance
|
65
|
+
include ::Safrano::Contract::Invalid
|
66
|
+
# can(should) be overriden in subclasses
|
67
|
+
def msg
|
68
|
+
@msg
|
69
|
+
end
|
70
|
+
|
46
71
|
def odata_get(req)
|
72
|
+
message = (m = msg.to_s).empty? ? self.class.to_s : m
|
47
73
|
if req.accept?(APPJSON)
|
48
|
-
|
49
|
-
|
74
|
+
# json is default content type so we dont need to specify it here again
|
75
|
+
[self.class.http_code, EMPTY_HASH,
|
76
|
+
{ 'odata.error' => { 'code' => "#{self.class.http_code}",
|
77
|
+
'type' => "#{self.class}",
|
78
|
+
'message' => message } }.to_json]
|
50
79
|
else
|
51
|
-
[self.class.
|
80
|
+
[self.class.http_code, CT_TEXT, message]
|
52
81
|
end
|
53
82
|
end
|
54
83
|
end
|
55
84
|
|
85
|
+
# generic http 500 server err
|
86
|
+
class ServerError
|
87
|
+
extend ErrorClass
|
88
|
+
HTTP_CODE = 500
|
89
|
+
@msg = 'Server error'
|
90
|
+
end
|
91
|
+
|
92
|
+
# for outputing Sequel exceptions that we could not prevent
|
93
|
+
class SequelExceptionError < ServerError
|
94
|
+
include ErrorInstance
|
95
|
+
def initialize(seqle7n)
|
96
|
+
@msg = seqle7n.message
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# for outputing Ruby StandardError exceptions that we could not prevent
|
101
|
+
class RubyStandardErrorException < ServerError
|
102
|
+
include ErrorInstance
|
103
|
+
def initialize(rubye7n)
|
104
|
+
@msg = rubye7n.message
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
56
108
|
# http Bad Req.
|
57
109
|
class BadRequestError
|
58
110
|
extend ErrorClass
|
111
|
+
include ErrorInstance
|
59
112
|
HTTP_CODE = 400
|
60
|
-
@msg = 'Bad Request
|
113
|
+
@msg = 'Bad Request'
|
114
|
+
def initialize(reason)
|
115
|
+
@msg = "Bad Request : #{reason}"
|
116
|
+
end
|
61
117
|
end
|
118
|
+
|
62
119
|
# Generic failed changeset
|
63
120
|
class BadRequestFailedChangeSet < BadRequestError
|
64
|
-
HTTP_CODE = 400
|
65
121
|
@msg = 'Bad Request: Failed changeset '
|
66
122
|
end
|
67
123
|
|
68
124
|
# $value request for a non-media entity
|
69
125
|
class BadRequestNonMediaValue < BadRequestError
|
70
|
-
HTTP_CODE = 400
|
71
126
|
@msg = 'Bad Request: $value request for a non-media entity'
|
72
127
|
end
|
73
128
|
class BadRequestSequelAdapterError < BadRequestError
|
@@ -79,77 +134,127 @@ module OData
|
|
79
134
|
|
80
135
|
# for Syntax error in Filtering
|
81
136
|
class BadRequestFilterParseError < BadRequestError
|
82
|
-
HTTP_CODE = 400
|
83
137
|
@msg = 'Bad Request: Syntax error in $filter'
|
84
138
|
end
|
85
139
|
|
86
|
-
# for
|
87
|
-
class
|
88
|
-
|
89
|
-
|
140
|
+
# for invalid attribute in path
|
141
|
+
class BadRequestInvalidAttribPath < BadRequestError
|
142
|
+
include ErrorInstance
|
143
|
+
def initialize(model, attr)
|
144
|
+
@msg = "Bad Request: the attribute #{attr} is invalid for entityset #{model.entity_set_name}"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# for invalid attribute in $expand param
|
149
|
+
class BadRequestExpandInvalidPath < BadRequestError
|
150
|
+
include ErrorInstance
|
151
|
+
def initialize(model, path)
|
152
|
+
@msg = "Bad Request: the $expand path #{path} is invalid for entityset #{model.entity_set_name}"
|
153
|
+
end
|
154
|
+
end
|
155
|
+
# for invalid properti(es) in $select param
|
156
|
+
class BadRequestSelectInvalidProps < BadRequestError
|
157
|
+
include ErrorInstance
|
158
|
+
def initialize(model, iprops)
|
159
|
+
@msg = ((iprops.size > 1) ? "Bad Request: the $select properties #{iprops.to_a.join(', ')} are invalid for entityset #{model.entity_set_name}" : "Bad Request: the $select property #{iprops.first} is invalid for entityset #{model.entity_set_name}")
|
160
|
+
end
|
90
161
|
end
|
91
162
|
|
92
163
|
# for Syntax error in $orderby param
|
93
164
|
class BadRequestOrderParseError < BadRequestError
|
94
|
-
HTTP_CODE = 400
|
95
165
|
@msg = 'Bad Request: Syntax error in $orderby'
|
96
166
|
end
|
97
167
|
|
98
168
|
# for $inlinecount error
|
99
169
|
class BadRequestInlineCountParamError < BadRequestError
|
100
|
-
HTTP_CODE = 400
|
101
170
|
@msg = 'Bad Request: wrong $inlinecount parameter'
|
102
171
|
end
|
172
|
+
|
103
173
|
# http not found
|
104
174
|
class ErrorNotFound
|
105
175
|
extend ErrorClass
|
106
176
|
HTTP_CODE = 404
|
107
177
|
@msg = 'The requested ressource was not found'
|
108
178
|
end
|
179
|
+
|
180
|
+
# http not found segment
|
181
|
+
class ErrorNotFoundSegment < ErrorNotFound
|
182
|
+
include ErrorInstance
|
183
|
+
|
184
|
+
def initialize(segment)
|
185
|
+
@msg = "The requested ressource segment #{segment} was not found"
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
109
189
|
# Transition error (Safrano specific)
|
110
|
-
class ServerTransitionError
|
111
|
-
extend ErrorClass
|
112
|
-
HTTP_CODE = 500
|
190
|
+
class ServerTransitionError < ServerError
|
113
191
|
@msg = 'Server error: Segment could not be parsed'
|
114
192
|
end
|
115
|
-
|
116
|
-
class ServerError
|
117
|
-
extend ErrorClass
|
118
|
-
HTTP_CODE = 500
|
119
|
-
@msg = 'Server error'
|
120
|
-
end
|
193
|
+
|
121
194
|
# not implemented (Safrano specific)
|
122
195
|
class NotImplementedError
|
123
196
|
extend ErrorClass
|
124
197
|
HTTP_CODE = 501
|
125
198
|
end
|
126
|
-
|
127
|
-
|
199
|
+
|
200
|
+
# version not implemented (Safrano specific)
|
201
|
+
class VersionNotImplementedError
|
128
202
|
extend ErrorClass
|
129
203
|
HTTP_CODE = 501
|
204
|
+
@msg = 'The requested OData version is not yet supported'
|
205
|
+
end
|
206
|
+
# batch not implemented (Safrano specific)
|
207
|
+
class BatchNotImplementedError < NotImplementedError
|
130
208
|
@msg = 'Not implemented: OData batch'
|
131
209
|
end
|
132
210
|
|
133
211
|
# error in filter parsing (Safrano specific)
|
134
212
|
class FilterParseError < BadRequestError
|
135
213
|
extend ErrorClass
|
136
|
-
HTTP_CODE = 400
|
137
214
|
end
|
138
215
|
|
139
216
|
class FilterFunctionNotImplementedError < BadRequestError
|
140
217
|
extend ErrorClass
|
141
218
|
include ErrorInstance
|
142
219
|
@msg = 'the requested $filter function is Not implemented'
|
143
|
-
|
144
|
-
|
145
|
-
@msg = exception.to_s
|
220
|
+
def initialize(xmsg)
|
221
|
+
@msg = xmsg
|
146
222
|
end
|
147
223
|
end
|
148
|
-
class
|
224
|
+
class FilterUnknownFunctionError < BadRequestError
|
225
|
+
include ErrorInstance
|
226
|
+
def initialize(badfuncname)
|
227
|
+
@msg = "Bad request: unknown function #{badfuncname} in $filter"
|
228
|
+
end
|
229
|
+
end
|
230
|
+
class FilterParseErrorWrongColumnName < BadRequestError
|
231
|
+
extend ErrorClass
|
232
|
+
@msg = 'Bad request: invalid property name in $filter'
|
233
|
+
end
|
234
|
+
class FilterParseWrappedError < BadRequestError
|
149
235
|
include ErrorInstance
|
150
|
-
HTTP_CODE = 400
|
151
236
|
def initialize(exception)
|
152
237
|
@msg = exception.to_s
|
153
238
|
end
|
154
239
|
end
|
240
|
+
class ServiceOperationParameterMissing < BadRequestError
|
241
|
+
include ErrorInstance
|
242
|
+
def initialize(missing:, sopname:)
|
243
|
+
@msg = "Bad request: Parameter(s) missing for for service operation #{sopname} : #{missing.join(', ')}"
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
class ServiceOperationParameterError < BadRequestError
|
248
|
+
include ErrorInstance
|
249
|
+
def initialize(type:, value:, param:, sopname:)
|
250
|
+
@type = type
|
251
|
+
@value = value
|
252
|
+
@param = param
|
253
|
+
@sopname = sopname
|
254
|
+
end
|
255
|
+
|
256
|
+
def msg
|
257
|
+
"Bad request: Parameter #{@param} with value '#{@value}' cannot be converted to type #{@type} for service operation #{@sopname}"
|
258
|
+
end
|
259
|
+
end
|
155
260
|
end
|
data/lib/odata/expand.rb
CHANGED
@@ -1,22 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'odata/error.rb'
|
2
4
|
|
3
5
|
# all dataset expanding related classes in our OData module
|
4
6
|
# ie do eager loading
|
5
|
-
module
|
7
|
+
module Safrano
|
6
8
|
# base class for expanding
|
7
9
|
class ExpandBase
|
8
10
|
EmptyExpand = new # re-useable empty expanding (idempotent)
|
9
11
|
EMPTYH = {}.freeze
|
10
12
|
|
11
|
-
def self.factory(expandstr)
|
12
|
-
expandstr.nil? ? EmptyExpand : MultiExpand.new(expandstr)
|
13
|
+
def self.factory(expandstr, model)
|
14
|
+
expandstr.nil? ? EmptyExpand : MultiExpand.new(expandstr, model)
|
13
15
|
end
|
14
16
|
|
15
17
|
# output template
|
16
18
|
attr_reader :template
|
17
19
|
|
18
20
|
def apply_to_dataset(dtcx)
|
19
|
-
dtcx
|
21
|
+
Contract.valid(dtcx)
|
20
22
|
end
|
21
23
|
|
22
24
|
def empty?
|
@@ -24,7 +26,7 @@ module OData
|
|
24
26
|
end
|
25
27
|
|
26
28
|
def parse_error?
|
27
|
-
|
29
|
+
Contract::OK
|
28
30
|
end
|
29
31
|
|
30
32
|
def template
|
@@ -60,7 +62,7 @@ module OData
|
|
60
62
|
end
|
61
63
|
|
62
64
|
def apply_to_dataset(dtcx)
|
63
|
-
dtcx
|
65
|
+
Contract.valid(dtcx)
|
64
66
|
end
|
65
67
|
|
66
68
|
def build_arg
|
@@ -70,11 +72,6 @@ module OData
|
|
70
72
|
@template = DEEPH_1.call(@nodes)
|
71
73
|
end
|
72
74
|
|
73
|
-
def parse_error?
|
74
|
-
# todo
|
75
|
-
false
|
76
|
-
end
|
77
|
-
|
78
75
|
def empty?
|
79
76
|
false
|
80
77
|
end
|
@@ -85,19 +82,22 @@ module OData
|
|
85
82
|
COMASPLIT = /\s*,\s*/.freeze
|
86
83
|
attr_reader :template
|
87
84
|
|
88
|
-
|
85
|
+
# Note: if you change this method, please also update arity_full_monkey?
|
86
|
+
# see below
|
87
|
+
def initialize(expandstr, model)
|
89
88
|
expandstr.strip!
|
89
|
+
@model = model
|
90
90
|
@expandp = expandstr
|
91
|
-
@exlist = []
|
92
91
|
|
93
|
-
@
|
92
|
+
@exstrlist = expandstr.split(COMASPLIT)
|
93
|
+
@exlist = @exstrlist.map { |exstr| Expand.new(exstr) }
|
94
94
|
build_template
|
95
95
|
end
|
96
96
|
|
97
97
|
def apply_to_dataset(dtcx)
|
98
98
|
# use eager loading for each used association
|
99
99
|
@exlist.each { |exp| dtcx = dtcx.eager(exp.arg) }
|
100
|
-
dtcx
|
100
|
+
Contract.valid(dtcx)
|
101
101
|
end
|
102
102
|
|
103
103
|
def build_template
|
@@ -112,8 +112,11 @@ module OData
|
|
112
112
|
end
|
113
113
|
|
114
114
|
def parse_error?
|
115
|
-
|
116
|
-
|
115
|
+
@exstrlist.each do |expstr|
|
116
|
+
return BadRequestExpandInvalidPath.new(@model, expstr) unless @model.expand_path_valid? expstr
|
117
|
+
end
|
118
|
+
|
119
|
+
Contract::OK
|
117
120
|
end
|
118
121
|
|
119
122
|
def empty?
|
data/lib/odata/filter/base.rb
CHANGED
data/lib/odata/filter/error.rb
CHANGED
@@ -1,35 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative '../error'
|
2
4
|
|
3
|
-
module
|
5
|
+
module Safrano
|
4
6
|
class SequelAdapterError < StandardError
|
5
7
|
attr_reader :inner
|
8
|
+
|
6
9
|
def initialize(err)
|
7
10
|
@inner = err
|
8
11
|
end
|
9
12
|
end
|
10
13
|
|
11
|
-
# exception to OData error bridge
|
12
|
-
module ErrorBridge
|
13
|
-
# return an odata error object wrapping the exception
|
14
|
-
# the odata error object should respond to odata_get for output
|
15
|
-
def odata_error
|
16
|
-
self.class.const_get('ODATA_ERROR_KLASS').new(self)
|
17
|
-
end
|
18
|
-
end
|
19
|
-
|
20
14
|
module Filter
|
21
|
-
class FunctionNotImplemented < StandardError
|
22
|
-
ODATA_ERROR_KLASS = OData::FilterFunctionNotImplementedError
|
23
|
-
include ::OData::ErrorBridge
|
24
|
-
end
|
25
|
-
|
26
15
|
class Parser
|
27
16
|
# Parser errors
|
28
|
-
|
17
|
+
|
18
|
+
class Error
|
19
|
+
def Error.http_code
|
20
|
+
const_get(:HTTP_CODE)
|
21
|
+
end
|
22
|
+
HTTP_CODE = 400
|
23
|
+
|
29
24
|
attr_reader :tok
|
30
25
|
attr_reader :typ
|
31
26
|
attr_reader :cur_val
|
32
27
|
attr_reader :cur_typ
|
28
|
+
|
33
29
|
def initialize(tok, typ, cur)
|
34
30
|
@tok = tok
|
35
31
|
@typ = typ
|
@@ -41,41 +37,61 @@ module OData
|
|
41
37
|
end
|
42
38
|
# Invalid Tokens
|
43
39
|
class ErrorInvalidToken < Error
|
40
|
+
include ::Safrano::ErrorInstance
|
41
|
+
def initialize(tok, typ, cur)
|
42
|
+
super
|
43
|
+
@msg = "Bad Request: invalid token #{tok} in $filter"
|
44
|
+
end
|
44
45
|
end
|
45
46
|
# Unmached closed
|
46
47
|
class ErrorUnmatchedClose < Error
|
48
|
+
include ::Safrano::ErrorInstance
|
49
|
+
def initialize(tok, typ, cur)
|
50
|
+
super
|
51
|
+
@msg = "Bad Request: unmatched #{tok} in $filter"
|
52
|
+
end
|
47
53
|
end
|
48
54
|
|
49
|
-
class ErrorFunctionArgumentType
|
55
|
+
class ErrorFunctionArgumentType
|
56
|
+
include ::Safrano::ErrorInstance
|
50
57
|
end
|
51
58
|
|
52
|
-
class ErrorWrongColumnName
|
59
|
+
class ErrorWrongColumnName
|
60
|
+
include ::Safrano::ErrorInstance
|
53
61
|
end
|
54
62
|
|
55
63
|
# attempt to add a child to a Leave
|
56
|
-
class ErrorLeaveChild
|
57
|
-
|
58
|
-
|
59
|
-
# invalid function error (literal attach to IdentityFuncTree)
|
60
|
-
class ErrorInvalidFunction < StandardError
|
61
|
-
ODATA_ERROR_KLASS = OData::FilterInvalidFunctionError
|
62
|
-
include ::OData::ErrorBridge
|
64
|
+
class ErrorLeaveChild
|
65
|
+
include ::Safrano::ErrorInstance
|
63
66
|
end
|
64
67
|
|
65
68
|
# Invalid function arity
|
66
69
|
class ErrorInvalidArity < Error
|
70
|
+
include ::Safrano::ErrorInstance
|
71
|
+
def initialize(tok, typ, cur)
|
72
|
+
super
|
73
|
+
@msg = "Bad Request: wrong number of parameters for function #{cur.parent.value.to_s} in $filter"
|
74
|
+
end
|
67
75
|
end
|
68
76
|
# Invalid separator in this context (missing parenthesis?)
|
69
77
|
class ErrorInvalidSeparator < Error
|
78
|
+
include ::Safrano::ErrorInstance
|
70
79
|
end
|
71
80
|
|
72
81
|
# unmatched quot3
|
73
82
|
class UnmatchedQuoteError < Error
|
83
|
+
include ::Safrano::ErrorInstance
|
84
|
+
def initialize(tok, typ, cur)
|
85
|
+
super
|
86
|
+
@msg = "Bad Request: unbalanced quotes #{tok} in $filter"
|
87
|
+
end
|
74
88
|
end
|
75
89
|
|
76
90
|
# wrong type of function argument
|
77
|
-
class ErrorInvalidArgumentType <
|
78
|
-
|
91
|
+
class ErrorInvalidArgumentType < Error
|
92
|
+
include ::Safrano::ErrorInstance
|
93
|
+
|
94
|
+
def initialize(tree, expected:, actual:)
|
79
95
|
@tree = tree
|
80
96
|
@expected = expected
|
81
97
|
@actual = actual
|