safrano 0.0.10 → 0.1.0
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/multipart.rb +472 -136
- data/lib/odata/batch.rb +40 -31
- data/lib/odata/collection.rb +25 -12
- data/lib/odata/collection_filter.rb +4 -6
- data/lib/odata/entity.rb +41 -8
- data/lib/rack_app.rb +2 -2
- data/lib/request.rb +21 -0
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 34ab64bf4fd2bbde8453e754091a3bd5618f41e6d51d9d15c9c678efcb38d544
|
4
|
+
data.tar.gz: d0ee4590389199a0e3530aee4a7aa244dde9355de8506a1091c5e0215ad40d78
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b9531e9de93d7d117ea1b58c1a90967706b102078513752f0bfef2447dc9c5988438d7ee1e9053827da0e58ac2aad07a6450a7c27b1fa5833ba329fe6c2bd4a8
|
7
|
+
data.tar.gz: 9e102e3c98ce0d47c0dd1dda33c8b2b76e0b7a3b9503d3af86c2ae08404773000b87442711ca230875d715148f90d870e9e63b9aae5c866677d4b0e8ca687ddd
|
data/lib/multipart.rb
CHANGED
@@ -1,182 +1,518 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
+
CRLF = "\r\n".freeze
|
4
|
+
LF = "\n".freeze
|
5
|
+
|
6
|
+
require 'securerandom'
|
7
|
+
require 'webrick/httpstatus'
|
8
|
+
|
3
9
|
# Simple multipart support for OData $batch purpose
|
10
|
+
module MIME
|
11
|
+
# a mime object has a header(with content-type etc) and a content(aka body)
|
12
|
+
class Media
|
13
|
+
# Parser for MIME::Media
|
14
|
+
class Parser
|
15
|
+
attr_accessor :lines
|
16
|
+
attr_accessor :target
|
17
|
+
def initialize
|
18
|
+
@state = :h
|
19
|
+
@lines = []
|
20
|
+
@target_hd = {}
|
21
|
+
@target_ct = nil
|
22
|
+
@sep = CRLF
|
23
|
+
end
|
4
24
|
|
5
|
-
|
6
|
-
|
7
|
-
HTTP_VERB_URI_REGEXP = /^(POST|GET|PUT|PATCH)\s+(\S*)\s?(\S*)$/.freeze
|
8
|
-
HEADER_REGEXP = %r{^([a-zA-Z\-]+):\s*([0-9a-zA-Z\-\/,\s]+;?\S*)\s*$}.freeze
|
9
|
-
module Parts
|
10
|
-
class Base
|
11
|
-
attr_accessor :parts
|
12
|
-
attr_reader :body
|
13
|
-
attr_reader :headers
|
14
|
-
|
15
|
-
def initialize(boundary)
|
16
|
-
@boundary = boundary
|
17
|
-
@headers = {}
|
18
|
-
@body = ''
|
19
|
-
@parts = []
|
25
|
+
def addline(line)
|
26
|
+
@lines << line
|
20
27
|
end
|
21
28
|
|
22
|
-
def
|
23
|
-
@
|
24
|
-
|
25
|
-
|
26
|
-
|
29
|
+
def parse(level: 0)
|
30
|
+
@level = level
|
31
|
+
return unless @lines
|
32
|
+
|
33
|
+
@lines.each do |line|
|
34
|
+
case @state
|
35
|
+
when :h
|
36
|
+
if (hmd = /^([\w-]+)\s*:\s*(.*)/.match(line))
|
37
|
+
@target_hd[hmd[1].downcase] = hmd[2].strip
|
38
|
+
|
39
|
+
elsif /^#{CRLF}$/ =~ line
|
40
|
+
@target_ct = @target_hd['content-type'] || 'text/plain'
|
41
|
+
@state = new_content
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
when :b
|
46
|
+
@target.parser.addline(line)
|
47
|
+
|
48
|
+
when :bmp
|
49
|
+
|
50
|
+
@state = :first_part if @target.parser.first_part(line)
|
51
|
+
|
52
|
+
when :first_part
|
53
|
+
if @target.parser.next_part(line)
|
54
|
+
@state = :next_part
|
55
|
+
# this is for when there is only one part
|
56
|
+
# (first part is the last one)
|
57
|
+
elsif @target.parser.last_part(line)
|
58
|
+
@state = :end
|
59
|
+
else
|
60
|
+
@target.parser.addline(line)
|
61
|
+
end
|
62
|
+
|
63
|
+
when :next_part
|
64
|
+
|
65
|
+
if @target.parser.next_part(line)
|
66
|
+
|
67
|
+
elsif @target.parser.last_part(line)
|
68
|
+
@state = :end
|
69
|
+
else
|
70
|
+
@target.parser.addline(line)
|
71
|
+
end
|
72
|
+
end
|
27
73
|
end
|
28
|
-
|
29
|
-
@
|
30
|
-
@
|
74
|
+
# Warning: recursive here
|
75
|
+
@target.parser.parse(level: level)
|
76
|
+
@target.parser = nil
|
77
|
+
@lines = nil
|
78
|
+
@target
|
31
79
|
end
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
super(boundary)
|
36
|
-
@headers['Content-Type'] = "multipart/mixed;boundary=#{@boundary}"
|
80
|
+
|
81
|
+
def multipart_content(boundary)
|
82
|
+
MIME::Content::Multipart::Base.new(boundary)
|
37
83
|
end
|
38
84
|
|
39
|
-
def
|
40
|
-
|
85
|
+
def hook_multipart(content_type, boundary)
|
86
|
+
@target_hd['content-type'] = content_type
|
87
|
+
@target_ct = @target_hd['content-type']
|
88
|
+
@target = multipart_content(boundary)
|
89
|
+
@target.hd = @target_hd
|
90
|
+
@target.ct = @target_ct
|
91
|
+
@state = :bmp
|
92
|
+
end
|
93
|
+
MP_RGX1 = %r{^multipart/(digest|mixed);\s*boundary=\"(.*)\"}.freeze
|
94
|
+
MP_RGX2 = %r{^multipart/(digest|mixed);\s*boundary=(.*)}.freeze
|
95
|
+
def new_content
|
96
|
+
@target =
|
97
|
+
if (md = MP_RGX1.match(@target_ct)) ||
|
98
|
+
(md = MP_RGX2.match(@target_ct))
|
99
|
+
multipart_content(md[2].strip)
|
100
|
+
elsif %r{^application/http} =~ @target_ct
|
101
|
+
MIME::Content::Application::Http.new
|
102
|
+
else
|
103
|
+
MIME::Content::Text::Plain.new
|
104
|
+
end
|
105
|
+
@target.hd.merge! @target_hd
|
106
|
+
@target.ct = @target_ct
|
107
|
+
@target.level = @level
|
108
|
+
@state = @target.is_a?(MIME::Content::Multipart::Base) ? :bmp : :b
|
41
109
|
end
|
42
110
|
|
43
|
-
def
|
44
|
-
@
|
45
|
-
|
46
|
-
|
47
|
-
|
111
|
+
def parse_lines(inp, level: 0)
|
112
|
+
@lines = inp
|
113
|
+
parse(level: level)
|
114
|
+
end
|
115
|
+
|
116
|
+
def parse_str(inpstr, level: 0)
|
117
|
+
# we need to keep the line separators --> use io.readlines
|
118
|
+
if inpstr.respond_to?(:readlines)
|
119
|
+
@lines = inpstr.readlines(@sep)
|
120
|
+
else
|
121
|
+
# rack input wrapper only has gets but not readlines
|
122
|
+
sepsave = $INPUT_RECORD_SEPARATOR
|
123
|
+
$INPUT_RECORD_SEPARATOR = @sep
|
124
|
+
while (line = inpstr.gets)
|
125
|
+
@lines << line
|
126
|
+
end
|
127
|
+
$INPUT_RECORD_SEPARATOR = sepsave
|
128
|
+
|
129
|
+
end
|
130
|
+
# tmp hack for test-tools that convert CRLF in payload to LF :-(
|
131
|
+
if @lines.size == 1
|
132
|
+
@sep = LF
|
133
|
+
@lines = @lines.first.split(LF).map { |xline| "#{xline}#{CRLF}" }
|
48
134
|
end
|
49
|
-
|
135
|
+
parse(level: level)
|
50
136
|
end
|
51
137
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
138
|
+
def parse_string(inpstr, level: 0)
|
139
|
+
@lines = inpstr.split(@sep)
|
140
|
+
if @lines.size == 1
|
141
|
+
@sep = LF
|
142
|
+
@lines = @lines.first.split(LF)
|
143
|
+
end
|
144
|
+
# split is not keeping the separator, we re-add it
|
145
|
+
@lines = @lines.map { |line| "#{line}#{CRLF}" }
|
146
|
+
parse(level: level)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
attr_accessor :content
|
151
|
+
attr_accessor :hd
|
152
|
+
attr_accessor :ct
|
153
|
+
attr_accessor :level
|
154
|
+
attr_reader :response
|
155
|
+
|
156
|
+
def initialize
|
157
|
+
@state = :h
|
158
|
+
@hd = {}
|
159
|
+
@ct = nil
|
160
|
+
@parser = Parser.new(self)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Subclasses for Mime media content
|
165
|
+
module Content
|
166
|
+
# due to the recursive nature of multipart media, a media-content
|
167
|
+
# should be seen as a full-fledged media(header+content) as well
|
168
|
+
class Media < ::MIME::Media
|
169
|
+
attr_accessor :parser
|
170
|
+
end
|
171
|
+
|
172
|
+
# text mime types
|
173
|
+
module Text
|
174
|
+
# Well, it says plain text
|
175
|
+
class Plain < Media
|
176
|
+
# Parser for Text::Plain
|
177
|
+
class Parser
|
178
|
+
def initialize(target)
|
179
|
+
@state = :h
|
180
|
+
@lines = []
|
181
|
+
@target = target
|
182
|
+
end
|
183
|
+
|
184
|
+
def addline(line)
|
185
|
+
@lines << line
|
186
|
+
end
|
187
|
+
|
188
|
+
def parse(level: 0)
|
189
|
+
return unless @lines
|
190
|
+
|
191
|
+
@level = level
|
192
|
+
@lines.each do |line|
|
193
|
+
case @state
|
194
|
+
when :h
|
195
|
+
if (hmd = /^([\w-]+)\s*:\s*(.*)/.match(line))
|
196
|
+
@target.hd[hmd[1].downcase] = hmd[2].strip
|
197
|
+
elsif /^#{CRLF}$/ =~ line
|
198
|
+
@state = :b
|
199
|
+
else
|
200
|
+
@target.content << line
|
201
|
+
@state = :b
|
202
|
+
end
|
203
|
+
when :b
|
204
|
+
@target.content << line
|
76
205
|
end
|
77
206
|
end
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
state = :hd
|
82
|
-
headers[md[1]] = md[2]
|
83
|
-
else
|
84
|
-
body_str << l
|
85
|
-
state = :bd
|
86
|
-
end
|
87
|
-
|
88
|
-
when :bd
|
89
|
-
body_str << l
|
207
|
+
@lines = nil
|
208
|
+
@target.level = level
|
209
|
+
@target
|
90
210
|
end
|
91
211
|
end
|
92
|
-
if ct_headers['Content-Type'] == 'application/http'
|
93
212
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
213
|
+
def initialize
|
214
|
+
@hd = {}
|
215
|
+
@content = ''
|
216
|
+
# set default values. Can be overwritten by parser
|
217
|
+
@hd['content-type'] = 'text/plain'
|
218
|
+
@ct = 'text/plain'
|
219
|
+
@parser = Parser.new(self)
|
220
|
+
end
|
98
221
|
|
99
|
-
|
100
|
-
|
101
|
-
|
222
|
+
def unparse
|
223
|
+
@content
|
224
|
+
end
|
225
|
+
|
226
|
+
def ==(other)
|
227
|
+
(@hd == other.hd) && (@content == other.content)
|
102
228
|
end
|
103
229
|
end
|
104
230
|
end
|
105
|
-
end
|
106
231
|
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
232
|
+
# Multipart media
|
233
|
+
module Multipart
|
234
|
+
# base class for multipart mixed, related, digest etc
|
235
|
+
class Base < Media
|
236
|
+
# Parser for Multipart Base class
|
237
|
+
class Parser
|
238
|
+
def initialize(target)
|
239
|
+
@body_lines = []
|
240
|
+
@target = target
|
241
|
+
@parts = []
|
242
|
+
@bound_rgx = /^--#{@target.boundary}\s*$/
|
243
|
+
@lastb_rgx = /^--#{@target.boundary}--\s*$/
|
244
|
+
end
|
245
|
+
|
246
|
+
def parse(level: 0)
|
247
|
+
@target.content = @parts.map do |mlines|
|
248
|
+
MIME::Media::Parser.new.parse_lines(mlines, level: level + 1)
|
249
|
+
end
|
250
|
+
@body_lines = @bound_rgx = @lastb_rgx = nil
|
251
|
+
@parts = nil
|
252
|
+
@target.level = level
|
253
|
+
@target
|
254
|
+
end
|
255
|
+
|
256
|
+
def first_part(line)
|
257
|
+
return unless @bound_rgx =~ line
|
258
|
+
|
259
|
+
@body_lines = []
|
260
|
+
end
|
261
|
+
|
262
|
+
def next_part(line)
|
263
|
+
return unless @bound_rgx =~ line
|
264
|
+
|
265
|
+
collect_part
|
266
|
+
@body_lines = []
|
267
|
+
end
|
268
|
+
|
269
|
+
def last_part(line)
|
270
|
+
return unless @lastb_rgx =~ line
|
271
|
+
|
272
|
+
collect_part
|
273
|
+
end
|
274
|
+
|
275
|
+
def collect_part
|
276
|
+
# according to the multipart RFC spec, the preceeding CRLF
|
277
|
+
# belongs to the boundary
|
278
|
+
# but because we are a CRLF line-based parser we need
|
279
|
+
# to remove it from the end of the last body line
|
280
|
+
return unless @body_lines
|
114
281
|
|
115
|
-
|
282
|
+
@body_lines.last.sub!(/#{CRLF}$/, '')
|
283
|
+
@parts << @body_lines
|
284
|
+
end
|
285
|
+
|
286
|
+
def addline(line)
|
287
|
+
@body_lines << line
|
288
|
+
end
|
289
|
+
end
|
116
290
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
291
|
+
attr_reader :boundary
|
292
|
+
|
293
|
+
def initialize(boundary)
|
294
|
+
@boundary = boundary
|
295
|
+
@hd = {}
|
296
|
+
@parser = Parser.new(self)
|
297
|
+
end
|
298
|
+
|
299
|
+
def ==(other)
|
300
|
+
(@boundary == other.boundary) && (@content == other.content)
|
301
|
+
end
|
302
|
+
|
303
|
+
def set_multipart_header
|
304
|
+
@hd['content-type'] = "multipart/mixed; boundary=#{@boundary}"
|
305
|
+
end
|
306
|
+
|
307
|
+
def get_http_resp(batcha)
|
308
|
+
get_response(batcha)
|
309
|
+
[@response.hd, @response.unparse(true)]
|
310
|
+
end
|
311
|
+
|
312
|
+
def get_response(batcha)
|
313
|
+
@response = self.class.new(::SecureRandom.uuid)
|
314
|
+
@response.set_multipart_header
|
315
|
+
if @level == 1 # changeset need their own global transaction
|
316
|
+
# the change requests that are part of if have @level==2
|
317
|
+
# and will be flagged with in_changeset=true
|
318
|
+
# and this will finally be used to skip the transaction
|
319
|
+
# of the changes
|
320
|
+
batcha.db.transaction do
|
321
|
+
begin
|
322
|
+
@response.content = @content.map { |part| part.get_response(batcha) }
|
323
|
+
rescue Sequel::Rollback => e
|
324
|
+
# one of the changes of the changeset has failed
|
325
|
+
# --> provide a dummy empty response for the change-parts
|
326
|
+
# then transmit the Rollback to Sequel
|
327
|
+
@response.content = @content.map { |_part|
|
328
|
+
MIME::Content::Application::HttpResp.new
|
329
|
+
}
|
330
|
+
raise
|
331
|
+
end
|
332
|
+
end
|
333
|
+
else
|
334
|
+
@response.content = @content.map { |part| part.get_response(batcha) }
|
335
|
+
end
|
336
|
+
@response
|
337
|
+
end
|
338
|
+
|
339
|
+
def unparse(bodyonly = false)
|
340
|
+
b = ''
|
341
|
+
unless bodyonly
|
342
|
+
b << 'Content-Type' << ': ' << @hd['content-type'] << CRLF
|
343
|
+
end
|
344
|
+
b << "#{CRLF}--#{@boundary}#{CRLF}"
|
345
|
+
b << @content.map(&:unparse).join("#{CRLF}--#{@boundary}#{CRLF}")
|
346
|
+
b << "#{CRLF}--#{@boundary}--#{CRLF}"
|
347
|
+
b
|
348
|
+
end
|
124
349
|
end
|
125
350
|
end
|
126
351
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
@http_method = method
|
134
|
-
@url = url
|
135
|
-
@body = body
|
136
|
-
end
|
352
|
+
# for application(http etc.) mime types
|
353
|
+
module Application
|
354
|
+
# Http Request
|
355
|
+
class HttpReq < Media
|
356
|
+
attr_accessor :http_method
|
357
|
+
attr_accessor :uri
|
137
358
|
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
@content << "#{k}: #{v}#{RETNL}"
|
359
|
+
def initialize
|
360
|
+
@hd = {}
|
361
|
+
@content = ''
|
142
362
|
end
|
143
|
-
|
144
|
-
|
145
|
-
@
|
363
|
+
|
364
|
+
def unparse
|
365
|
+
b = "#{@http_method} #{@uri} HTTP/1.1#{CRLF}"
|
366
|
+
@hd.each { |k, v| b << k.to_s << ': ' << v.to_s << CRLF }
|
367
|
+
b << CRLF
|
368
|
+
b << @content if @content != ''
|
369
|
+
b
|
146
370
|
end
|
147
|
-
@content
|
148
371
|
end
|
149
372
|
|
150
|
-
|
151
|
-
|
152
|
-
|
373
|
+
# Http Response
|
374
|
+
class HttpResp < Media
|
375
|
+
attr_accessor :status
|
376
|
+
attr_accessor :content
|
153
377
|
|
154
|
-
|
155
|
-
|
156
|
-
|
378
|
+
APPLICATION_HTTP_11 = ['Content-Type: application/http',
|
379
|
+
"Content-Transfer-Encoding: binary#{CRLF}",
|
380
|
+
'HTTP/1.1 '].join(CRLF).freeze
|
157
381
|
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
382
|
+
StatusMessage = ::WEBrick::HTTPStatus::StatusMessage.freeze
|
383
|
+
|
384
|
+
def initialize
|
385
|
+
@hd = {}
|
386
|
+
@content = []
|
162
387
|
end
|
163
388
|
|
164
|
-
|
165
|
-
|
389
|
+
def ==(other)
|
390
|
+
(@hd == other.hd) && (@content == other.content)
|
391
|
+
end
|
166
392
|
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
393
|
+
def unparse
|
394
|
+
b = String.new(APPLICATION_HTTP_11)
|
395
|
+
b << "#{@status} #{StatusMessage[@status]} #{CRLF}"
|
396
|
+
@hd.each { |k, v| b << k.to_s << ': ' << v.to_s << CRLF }
|
397
|
+
b << CRLF
|
398
|
+
b << @content.join if @content
|
399
|
+
b
|
400
|
+
end
|
173
401
|
end
|
174
|
-
end
|
175
402
|
|
176
|
-
|
177
|
-
|
178
|
-
|
403
|
+
# For application/http . Content is either a Request or a Response
|
404
|
+
class Http < Media
|
405
|
+
HTTP_R_RGX = %r{^(POST|GET|PUT|MERGE|PATCH)\s+(\S*)\s?(HTTP/1\.1)\s*$}.freeze
|
406
|
+
HEADER_RGX = %r{^([a-zA-Z\-]+):\s*([0-9a-zA-Z\-\/,\s]+;?\S*)\s*$}.freeze
|
407
|
+
HTTP_RESP_RGX = %r{^HTTP/1\.1\s(\d+)\s}.freeze
|
408
|
+
|
409
|
+
# Parser for Http Media
|
410
|
+
class Parser
|
411
|
+
def initialize(target)
|
412
|
+
@state = :http
|
413
|
+
@lines = []
|
414
|
+
@body_lines = []
|
415
|
+
@target = target
|
416
|
+
end
|
417
|
+
|
418
|
+
def addline(line)
|
419
|
+
@lines << line
|
420
|
+
end
|
421
|
+
|
422
|
+
def parse(level: 0)
|
423
|
+
return unless @lines
|
424
|
+
|
425
|
+
@lines.each do |line|
|
426
|
+
case @state
|
427
|
+
when :http
|
428
|
+
if (hmd = /^([\w-]+)\s*:\s*(.*)/.match(line))
|
429
|
+
@target.hd[hmd[1].downcase] = hmd[2].strip
|
430
|
+
elsif (mdht = HTTP_R_RGX.match(line))
|
431
|
+
@state = :hd
|
432
|
+
@target.content = MIME::Content::Application::HttpReq.new
|
433
|
+
@target.content.http_method = mdht[1]
|
434
|
+
@target.content.uri = mdht[2]
|
435
|
+
# HTTP 1.1 status line --> HttpResp.new
|
436
|
+
elsif (mdht = HTTP_RESP_RGX.match(line))
|
437
|
+
@state = :hd
|
438
|
+
@target.content = MIME::Content::Application::HttpResp.new
|
439
|
+
@target.content.status = mdht[1]
|
440
|
+
end
|
441
|
+
when :hd
|
442
|
+
if (hmd = /^([\w-]+)\s*:\s*(.*)/.match(line))
|
443
|
+
@target.content.hd[hmd[1].downcase] = hmd[2].strip
|
444
|
+
elsif /^#{CRLF}$/ =~ line
|
445
|
+
@state = :b
|
446
|
+
else
|
447
|
+
@body_lines << line
|
448
|
+
@state = :b
|
449
|
+
end
|
450
|
+
when :b
|
451
|
+
@body_lines << line
|
452
|
+
end
|
453
|
+
end
|
454
|
+
|
455
|
+
@target.content.content = @body_lines.join
|
456
|
+
@target.content.level = level
|
457
|
+
@target.level = level
|
458
|
+
@lines = nil
|
459
|
+
@body_lines = nil
|
460
|
+
@target
|
461
|
+
end
|
462
|
+
end
|
463
|
+
|
464
|
+
def initialize
|
465
|
+
@hd = {}
|
466
|
+
@ct = 'application/http'
|
467
|
+
@parser = Parser.new(self)
|
468
|
+
end
|
469
|
+
|
470
|
+
def ==(other)
|
471
|
+
@content = other.content
|
472
|
+
end
|
473
|
+
|
474
|
+
def get_response(batchapp)
|
475
|
+
# self.content should be the request
|
476
|
+
rack_resp = batchapp.batch_call(@content)
|
477
|
+
@response = MIME::Content::Application::HttpResp.new
|
478
|
+
@response.status = rack_resp[0]
|
479
|
+
@response.hd = rack_resp[1]
|
480
|
+
@response.content = rack_resp[2]
|
481
|
+
@response
|
482
|
+
end
|
483
|
+
|
484
|
+
def unparse
|
485
|
+
b = "Content-Type: #{@ct}#{CRLF}"
|
486
|
+
b << "Content-Transfer-Encoding: binary#{CRLF}#{CRLF}"
|
487
|
+
b << @content.unparse
|
488
|
+
b
|
489
|
+
end
|
179
490
|
end
|
180
491
|
end
|
181
492
|
end
|
182
493
|
end
|
494
|
+
|
495
|
+
# @mimep = MIME::Media::Parser.new
|
496
|
+
|
497
|
+
# @inpstr = File.open('../test/multipart/odata_changeset_1_body.txt','r')
|
498
|
+
# @boundary = 'batch_48f8-3aea-2f04'
|
499
|
+
|
500
|
+
# @inpstr = File.open('../test/multipart/odata_changeset_body.txt','r')
|
501
|
+
# @boundary = 'batch_36522ad7-fc75-4b56-8c71-56071383e77b'
|
502
|
+
|
503
|
+
# @mime = @mimep.hook_multipart('multipart/mixed', @boundary)
|
504
|
+
# @mime = @mimep.parse_str(@inpstr)
|
505
|
+
|
506
|
+
# require 'pry'
|
507
|
+
# binding.pry
|
508
|
+
|
509
|
+
# @inpstr.close
|
510
|
+
|
511
|
+
# inp = File.open(ARGV[0], 'r') do |f|
|
512
|
+
# f.readlines(CRLF)
|
513
|
+
# end
|
514
|
+
# x = MIME::Media::Parser.new
|
515
|
+
# y = x.parsein(inp)
|
516
|
+
# pp y
|
517
|
+
# puts '-----------------------------------------------'
|
518
|
+
# puts y.unparse
|
data/lib/odata/batch.rb
CHANGED
@@ -2,33 +2,19 @@
|
|
2
2
|
|
3
3
|
require 'rack_app.rb'
|
4
4
|
require 'safrano_core.rb'
|
5
|
-
require 'webrick/httpstatus'
|
6
5
|
|
7
6
|
module OData
|
8
|
-
|
7
|
+
# Support for OData multipart $batch Requests
|
9
8
|
class Request
|
10
9
|
def create_batch_app
|
11
10
|
Batch::MyOApp.new(self)
|
12
11
|
end
|
13
12
|
|
14
|
-
def
|
13
|
+
def parse_multipart
|
14
|
+
@mimep = MIME::Media::Parser.new
|
15
15
|
@boundary = media_type_params['boundary']
|
16
|
-
|
17
|
-
|
18
|
-
end
|
19
|
-
|
20
|
-
class Response
|
21
|
-
APPLICATION_HTTP_11 = ['Content-Type: application/http',
|
22
|
-
"Content-Transfer-Encoding: binary#{RETNEW}",
|
23
|
-
'HTTP/1.1 '].join(RETNEW).freeze
|
24
|
-
StatusMessage = WEBrick::HTTPStatus::StatusMessage.freeze
|
25
|
-
def message_s
|
26
|
-
b = String.new(APPLICATION_HTTP_11)
|
27
|
-
b << "#{@status} #{StatusMessage[@status]} #{RETNEW}"
|
28
|
-
headers.each { |k, v| b << k.to_s << ':' << v.to_s << RETNEW }
|
29
|
-
b << RETNEW
|
30
|
-
b << @body[0].to_s
|
31
|
-
b << RETNEW
|
16
|
+
@mimep.hook_multipart(media_type, @boundary)
|
17
|
+
@mimep.parse_str(body)
|
32
18
|
end
|
33
19
|
end
|
34
20
|
|
@@ -37,9 +23,11 @@ module OData
|
|
37
23
|
class MyOApp < OData::ServerApp
|
38
24
|
attr_reader :full_req
|
39
25
|
attr_reader :response
|
26
|
+
attr_reader :db
|
40
27
|
|
41
28
|
def initialize(full_req)
|
42
29
|
@full_req = full_req
|
30
|
+
@db = full_req.service.collections.first.db
|
43
31
|
end
|
44
32
|
|
45
33
|
# redefined for $batch
|
@@ -50,16 +38,40 @@ module OData
|
|
50
38
|
end
|
51
39
|
|
52
40
|
def batch_call(part_req)
|
53
|
-
env = part_req
|
41
|
+
env = batch_env(part_req)
|
54
42
|
env['HTTP_HOST'] = @full_req.env['HTTP_HOST']
|
55
43
|
|
56
44
|
@request = OData::Request.new(env)
|
57
45
|
@response = OData::Response.new
|
58
46
|
|
47
|
+
@request.in_changeset = true if part_req.level == 2
|
48
|
+
|
59
49
|
before
|
60
50
|
dispatch
|
51
|
+
|
61
52
|
@response.finish
|
62
53
|
end
|
54
|
+
|
55
|
+
# shamelessely copied from Rack::TEST:Session
|
56
|
+
def headers_for_env(headers)
|
57
|
+
converted_headers = {}
|
58
|
+
|
59
|
+
headers.each do |name, value|
|
60
|
+
env_key = name.upcase.tr('-', '_')
|
61
|
+
env_key = 'HTTP_' + env_key unless env_key == 'CONTENT_TYPE'
|
62
|
+
converted_headers[env_key] = value
|
63
|
+
end
|
64
|
+
|
65
|
+
converted_headers
|
66
|
+
end
|
67
|
+
|
68
|
+
def batch_env(mime_req)
|
69
|
+
@env = ::Rack::MockRequest.env_for(mime_req.uri,
|
70
|
+
method: mime_req.http_method,
|
71
|
+
input: mime_req.content)
|
72
|
+
@env.merge! headers_for_env(mime_req.hd)
|
73
|
+
@env
|
74
|
+
end
|
63
75
|
end
|
64
76
|
|
65
77
|
# Huile d'olive extra
|
@@ -91,9 +103,7 @@ module OData
|
|
91
103
|
attr_accessor :parts
|
92
104
|
attr_accessor :request
|
93
105
|
|
94
|
-
def initialize
|
95
|
-
@parts = []
|
96
|
-
end
|
106
|
+
def initialize; end
|
97
107
|
|
98
108
|
# here we are in the Batch handler object, and this POST should
|
99
109
|
# normally handle a $batch request
|
@@ -101,15 +111,14 @@ module OData
|
|
101
111
|
@request = req
|
102
112
|
|
103
113
|
if @request.media_type == 'multipart/mixed'
|
114
|
+
|
104
115
|
batcha = @request.create_batch_app
|
105
|
-
@mult_request = @request.
|
106
|
-
@mult_response =
|
107
|
-
|
108
|
-
@mult_request.
|
109
|
-
|
110
|
-
|
111
|
-
end
|
112
|
-
[202, @mult_response.headers, @mult_response.finalize]
|
116
|
+
@mult_request = @request.parse_multipart
|
117
|
+
@mult_response = OData::Response.new
|
118
|
+
|
119
|
+
resp_hdrs, @mult_response.body = @mult_request.get_http_resp(batcha)
|
120
|
+
|
121
|
+
[202, resp_hdrs, @mult_response.body[0]]
|
113
122
|
else
|
114
123
|
[415, {}, 'Unsupported Media Type']
|
115
124
|
end
|
data/lib/odata/collection.rb
CHANGED
@@ -82,7 +82,7 @@ module OData
|
|
82
82
|
end
|
83
83
|
|
84
84
|
# Factory json-> Model Object instance
|
85
|
-
def new_from_hson_h(hash)
|
85
|
+
def new_from_hson_h(hash, in_changeset: false)
|
86
86
|
enty = new
|
87
87
|
hash.delete('__metadata')
|
88
88
|
# DONE: move this somewhere else where it's evaluated only once at setup
|
@@ -90,7 +90,8 @@ module OData
|
|
90
90
|
# cattr[:primary_key] ? nil : col
|
91
91
|
# end.select { |col| col }
|
92
92
|
enty.set_fields(hash, @data_fields, missing: :skip)
|
93
|
-
|
93
|
+
# in-changeset requests get their own transaction
|
94
|
+
enty.save(transaction: !in_changeset)
|
94
95
|
enty
|
95
96
|
end
|
96
97
|
|
@@ -206,7 +207,7 @@ module OData
|
|
206
207
|
def check_u_p_orderby
|
207
208
|
# TODO: this should be moved into OData::Order somehow,
|
208
209
|
# at least partly
|
209
|
-
return unless
|
210
|
+
return unless @params['$orderby']
|
210
211
|
|
211
212
|
pordlist = @params['$orderby'].dup
|
212
213
|
pordlist.split(',').each do |pord|
|
@@ -278,7 +279,7 @@ module OData
|
|
278
279
|
else
|
279
280
|
[200, { 'Content-Type' => 'application/json;charset=utf-8' },
|
280
281
|
to_odata_json(service: req.service)]
|
281
|
-
|
282
|
+
end
|
282
283
|
else # TODO: other formats
|
283
284
|
406
|
284
285
|
end
|
@@ -304,12 +305,22 @@ module OData
|
|
304
305
|
def odata_post(req)
|
305
306
|
# TODO: check Request body format...
|
306
307
|
# TODO: this is for v2 only...
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
308
|
+
on_error = (proc { raise Sequel::Rollback } if req.in_changeset)
|
309
|
+
req.with_parsed_data(on_error: on_error) do |data|
|
310
|
+
data.delete('__metadata')
|
311
|
+
# validate payload column names
|
312
|
+
if (invalid = invalid_hash_data?(data))
|
313
|
+
on_error.call if on_error
|
314
|
+
return [422, {}, ['Invalid attribute name: ', invalid.to_s]]
|
315
|
+
end
|
316
|
+
|
317
|
+
if req.accept?('application/json')
|
318
|
+
[201, { 'Content-Type' => 'application/json;charset=utf-8' },
|
319
|
+
new_from_hson_h(data, in_changeset: req.in_changeset)
|
320
|
+
.to_odata_post_json(service: req.service)]
|
321
|
+
else # TODO: other formats
|
322
|
+
415
|
323
|
+
end
|
313
324
|
end
|
314
325
|
end
|
315
326
|
|
@@ -377,6 +388,10 @@ module OData
|
|
377
388
|
end.select { |col| col }
|
378
389
|
end
|
379
390
|
|
391
|
+
def invalid_hash_data?(data)
|
392
|
+
data.keys.map(&:to_sym).find { |ksym| !(@columns.include? ksym) }
|
393
|
+
end
|
394
|
+
|
380
395
|
# A regexp matching all allowed attributes of the Entity
|
381
396
|
# (eg ID|name|size etc... )
|
382
397
|
def attribute_url_regexp
|
@@ -396,9 +411,7 @@ module OData
|
|
396
411
|
end
|
397
412
|
|
398
413
|
def transition_id(match_result)
|
399
|
-
# binding.pry
|
400
414
|
if (id = match_result[1])
|
401
|
-
# puts "in transition_id, found #{y}"
|
402
415
|
if (y = find(id))
|
403
416
|
[y, :run]
|
404
417
|
else
|
@@ -4,9 +4,7 @@ require 'odata/error.rb'
|
|
4
4
|
|
5
5
|
# a few helper method
|
6
6
|
class String
|
7
|
-
# MASK_RGX = /'([^']*)'/.freeze
|
8
7
|
MASK_RGX = /'((?:[^']|(?:\'{2}))*)'/.freeze
|
9
|
-
# QUOTED_RGX = /'((?:[^']|(?:\'{2}))*)'/.freeze
|
10
8
|
UNMASK_RGX = /'(%?)(\$\d+)(%?)'/.freeze
|
11
9
|
def with_mask_quoted_substrings!
|
12
10
|
cnt = 0
|
@@ -261,10 +259,10 @@ module OData
|
|
261
259
|
def get_assoc
|
262
260
|
@assoc, @fn = @fn.split('/') if @fn.include?('/')
|
263
261
|
assoc1, @fn1 = @fn1.split('/') if @fn1.include?('/')
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
262
|
+
return unless assoc1 != @assoc
|
263
|
+
|
264
|
+
# TODO... handle this
|
265
|
+
raise OData::ServerError
|
268
266
|
end
|
269
267
|
|
270
268
|
def get_qualified_fn1(dtcx)
|
data/lib/odata/entity.rb
CHANGED
@@ -151,7 +151,12 @@ module OData
|
|
151
151
|
if req.accept?('application/json')
|
152
152
|
data.delete('__metadata')
|
153
153
|
|
154
|
-
|
154
|
+
if req.in_changeset
|
155
|
+
set_fields(data, self.class.data_fields, missing: :skip)
|
156
|
+
save(transaction: false)
|
157
|
+
else
|
158
|
+
update_fields(data, self.class.data_fields, missing: :skip)
|
159
|
+
end
|
155
160
|
|
156
161
|
[202, to_odata_post_json(service: req.service)]
|
157
162
|
else # TODO: other formats
|
@@ -160,17 +165,45 @@ module OData
|
|
160
165
|
end
|
161
166
|
|
162
167
|
def odata_patch(req)
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
if req.accept?('application/json')
|
168
|
+
on_error = (proc { raise Sequel::Rollback } if req.in_changeset)
|
169
|
+
req.with_parsed_data(on_error: on_error) do |data|
|
167
170
|
data.delete('__metadata')
|
168
|
-
|
171
|
+
|
172
|
+
# validate payload column names
|
173
|
+
if (invalid = self.class.invalid_hash_data?(data))
|
174
|
+
on_error.call if on_error
|
175
|
+
return [422, {}, ['Invalid attribute name: ', invalid.to_s]]
|
176
|
+
end
|
177
|
+
# TODO: check values/types
|
178
|
+
|
179
|
+
my_data_fields = self.class.data_fields
|
180
|
+
@uribase = req.uribase
|
181
|
+
# if req.accept?('application/json')
|
182
|
+
|
183
|
+
if req.in_changeset
|
184
|
+
set_fields(data, my_data_fields, missing: :skip)
|
185
|
+
save(transaction: false)
|
186
|
+
else
|
187
|
+
update_fields(data, my_data_fields, missing: :skip)
|
188
|
+
end
|
169
189
|
# patch should return 204 + no content
|
170
190
|
[204, {}, []]
|
171
|
-
else # TODO: other formats
|
172
|
-
415
|
173
191
|
end
|
192
|
+
|
193
|
+
# if ( req.content_type == 'application/json' )
|
194
|
+
## Parse json payload
|
195
|
+
# begin
|
196
|
+
# data = JSON.parse(req.body.read)
|
197
|
+
# rescue JSON::ParserError => e
|
198
|
+
# return [400, {}, ['JSON Parser Error while parsing payload : ',
|
199
|
+
# e.message]]
|
200
|
+
|
201
|
+
# end
|
202
|
+
|
203
|
+
# else # TODO: other formats
|
204
|
+
|
205
|
+
# [415, {}, []]
|
206
|
+
# end
|
174
207
|
end
|
175
208
|
|
176
209
|
# redefinitions of the main methods for a navigated collection
|
data/lib/rack_app.rb
CHANGED
@@ -77,7 +77,7 @@ module OData
|
|
77
77
|
# the main Rack server app. Source: the Rack docu/examples and partly
|
78
78
|
# inspired from Sinatra
|
79
79
|
class ServerApp
|
80
|
-
METHODS_REGEXP = Regexp.new('HEAD|OPTIONS|GET|POST|PATCH|PUT|DELETE')
|
80
|
+
METHODS_REGEXP = Regexp.new('HEAD|OPTIONS|GET|POST|PATCH|MERGE|PUT|DELETE')
|
81
81
|
include MethodHandlers
|
82
82
|
def before
|
83
83
|
headers 'Cache-Control' => 'no-cache'
|
@@ -108,7 +108,7 @@ module OData
|
|
108
108
|
odata_delete
|
109
109
|
when 'OPTIONS'
|
110
110
|
odata_options
|
111
|
-
when 'PATCH', 'PUT'
|
111
|
+
when 'PATCH', 'PUT', 'MERGE'
|
112
112
|
odata_patch
|
113
113
|
else
|
114
114
|
raise Error
|
data/lib/request.rb
CHANGED
@@ -13,6 +13,8 @@ module OData
|
|
13
13
|
HEADER_VAL_RAW = '(?:\w+|\*)\/(?:\w+(?:\.|\-|\+)?|\*)*'.freeze
|
14
14
|
HEADER_VAL_WITH_PAR = /(?:#{HEADER_VAL_RAW})\s*(?:;#{HEADER_PARAM})*/.freeze
|
15
15
|
|
16
|
+
attr_accessor :in_changeset
|
17
|
+
|
16
18
|
# borowed from Sinatra
|
17
19
|
class AcceptEntry
|
18
20
|
attr_accessor :params
|
@@ -115,6 +117,25 @@ module OData
|
|
115
117
|
end
|
116
118
|
end
|
117
119
|
|
120
|
+
def with_parsed_data(on_error: nil)
|
121
|
+
if content_type == 'application/json'
|
122
|
+
# Parse json payload
|
123
|
+
begin
|
124
|
+
data = JSON.parse(body.read)
|
125
|
+
rescue JSON::ParserError => e
|
126
|
+
on_error.call if on_error
|
127
|
+
return [400, {}, ['JSON Parser Error while parsing payload : ',
|
128
|
+
e.message]]
|
129
|
+
end
|
130
|
+
|
131
|
+
yield data
|
132
|
+
|
133
|
+
else # TODO: other formats
|
134
|
+
|
135
|
+
[415, {}, []]
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
118
139
|
def negotiate_service_version
|
119
140
|
maxv = if (rqv = env['HTTP_MAXDATASERVICEVERSION'])
|
120
141
|
OData::ServiceBase.parse_data_service_version(rqv)
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: safrano
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- D.M.
|
@@ -120,7 +120,11 @@ files:
|
|
120
120
|
homepage: https://gitlab.com/dm0da/safrano
|
121
121
|
licenses:
|
122
122
|
- MIT
|
123
|
-
metadata:
|
123
|
+
metadata:
|
124
|
+
bug_tracker_uri: https://gitlab.com/dm0da/safrano/issues
|
125
|
+
changelog_uri: https://gitlab.com/dm0da/safrano/blob/master/CHANGELOG
|
126
|
+
source_code_uri: https://gitlab.com/dm0da/safrano/tree/master
|
127
|
+
wiki_uri: https://gitlab.com/dm0da/safrano/wikis/home
|
124
128
|
post_install_message:
|
125
129
|
rdoc_options: []
|
126
130
|
require_paths:
|
@@ -139,5 +143,5 @@ requirements: []
|
|
139
143
|
rubygems_version: 3.0.3
|
140
144
|
signing_key:
|
141
145
|
specification_version: 4
|
142
|
-
summary: Safrano is a
|
146
|
+
summary: Safrano is a Ruby OData provider based on Sequel and Rack
|
143
147
|
test_files: []
|