safrano 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49dc75cf9bfcdff0a03b38eaada7f0a968a09ab3777327f0c893f8f39fd32d8f
4
- data.tar.gz: 7103e882037de0e6c89d368ec25cb0f6c6b9ed4be7fc8dff9aa64d0a6107a29d
3
+ metadata.gz: d62d07c9e0df09819503c296c06aad10d160915649158c3e8aff15368880e2a4
4
+ data.tar.gz: 9666f85a2826483709ed963fe6a48fef768b5fe78c3adef504d2b8c412f86703
5
5
  SHA512:
6
- metadata.gz: e7dbd2370716c6e5f323c9fff63394053521f70af27ee613a288e7ca089071818844a09dc4e400cba4fcd0e227e4c7d38b5d07d2e887aa33553cf8ea57a1e5ec
7
- data.tar.gz: 94b84b19d4d7273a0aef7c4a52348e4e77340f95cf64792e6f47dc1cde8e06ae675ee3bc5cd87d8c58e8e8098b914b24bb4e38818f96f75fd2116f32ae6e0d8b
6
+ metadata.gz: c8f61683c7bbdb3f8f45aa70e760d60b02e7acfca8ee6027d869f33f5fa16b475d85a04b549f132506e7653b064658947f8b6e8cdc2bdc1dea8fb992699e0155
7
+ data.tar.gz: 682bc1b1bba6e68c3f2855f6242aa3119883bbf1baacab74e90ee4671d5e827508eaad1d55eda9f596bcedfdeff5953a5c0e09599c094bd53b1d5e6dcfa2ffa9
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rack_app.rb'
4
+ require 'safrano_core.rb'
5
+
6
+ module OData
7
+ module Batch
8
+ # Mayonaise
9
+ class MyOApp < OData::ServerApp
10
+ attr_writer :full_req
11
+
12
+ def batch_call(env)
13
+ @request = OData::Request.new(env)
14
+ @response = OData::Response.new
15
+
16
+ dispatch
17
+
18
+ @response.finish
19
+ end
20
+
21
+ def batch_body
22
+ b = ''
23
+ @response.headers.each { |k, v| b << k.to_s << ':' << v.to_s << "\r\n" }
24
+ b << "\r\n"
25
+ b << @response.body[0].to_s
26
+ b << "\r\n"
27
+ end
28
+ end
29
+
30
+ # Moutarde
31
+ class RequestPartBuilder
32
+ attr_accessor :body_str
33
+ attr_accessor :odata_headers
34
+ attr_accessor :headers
35
+ attr_accessor :http_method
36
+ attr_accessor :uri
37
+ attr_accessor :env
38
+ def initialize
39
+ @headers = {}
40
+ @odata_headers = {}
41
+ @body_str = ''
42
+ end
43
+
44
+ # shamelessely copied from Rack::TEST:Session
45
+ def headers_for_env
46
+ converted_headers = {}
47
+
48
+ @headers.each do |name, value|
49
+ env_key = name.upcase.tr('-', '_')
50
+ env_key = 'HTTP_' + env_key unless env_key == 'CONTENT_TYPE'
51
+ converted_headers[env_key] = value
52
+ end
53
+
54
+ converted_headers
55
+ end
56
+
57
+ def batch_env
58
+ @env = ::Rack::MockRequest.env_for(@uri,
59
+ method: @http_method,
60
+ input: @body_str)
61
+ @env.merge! headers_for_env
62
+ @env
63
+ end
64
+ end
65
+ # Huile d'olive extra
66
+ class HandlerBase
67
+ TREND = Safrano::Transition.new('', trans: 'transition_end')
68
+ def allowed_transitions
69
+ @allowed_transitions = [TREND]
70
+ end
71
+
72
+ def transition_end(_match_result)
73
+ [nil, :end]
74
+ end
75
+ end
76
+ # jaune d'oeuf
77
+ class DisabledHandler < HandlerBase
78
+ def odata_post(_req)
79
+ [404, {}, '$batch is not enabled ']
80
+ end
81
+
82
+ def odata_get(_req)
83
+ [404, {}, '$batch is not enabled ']
84
+ end
85
+ end
86
+ # battre le tout
87
+ class EnabledHandler < HandlerBase
88
+ HTTP_VERB_URI_REGEXP = /^(POST|GET|PUT|PATCH)\s+(\S*)\s?(\S*)$/.freeze
89
+ HEADER_REGEXP = %r{^([a-zA-Z\-]+):\s*([0-9a-zA-Z\-\/]+;?\S*)\s*$}.freeze
90
+ attr_accessor :boundary
91
+ attr_accessor :mmboundary
92
+ attr_accessor :body_str
93
+ attr_accessor :parts
94
+ attr_accessor :request
95
+
96
+ def initialize
97
+ @parts = []
98
+ end
99
+
100
+ def odata_post(req)
101
+ odata_batch(req)
102
+ end
103
+
104
+ def odata_get(_req)
105
+ [405, {}, 'You cant GET $batch, POST it ']
106
+ end
107
+
108
+ def odata_batch(req)
109
+ @request = req
110
+ if @request.media_type == 'multipart/mixed'
111
+ read
112
+ parse
113
+ [202,
114
+ { 'Content-Type' => "multipart/mixed;boundary=#{@boundary}" },
115
+ process_parts]
116
+ else
117
+ [415, {}, 'Unsupported Media Type']
118
+ end
119
+ end
120
+
121
+ def read
122
+ @body_str = @request.body.read
123
+ @boundary = @request.media_type_params['boundary']
124
+ @mmboundary = '--' << @boundary
125
+ end
126
+
127
+ def parse
128
+ body_str.split(@mmboundary).each do |p|
129
+ @parts << parse_part(p) unless (p == '--\n') || p == '--' || p.empty?
130
+ end
131
+ end
132
+
133
+ # Ultrasimplified parser...
134
+ def parse_part(inp)
135
+ state = :start
136
+ part = RequestPartBuilder.new
137
+ inp.each_line do |l|
138
+ next if l.strip.empty?
139
+
140
+ case state
141
+ when :start, :ohd
142
+ md = l.strip.match(HEADER_REGEXP)
143
+
144
+ if md
145
+ state = :ohd
146
+ part.odata_headers[md[1]] = md[2]
147
+ else
148
+ md = l.strip.match(HTTP_VERB_URI_REGEXP)
149
+ if md
150
+ state = :hd
151
+ part.http_method = md[1]
152
+ part.uri = md[2]
153
+ end
154
+ end
155
+ when :hd
156
+ md = l.strip.match(HEADER_REGEXP)
157
+ if md
158
+ state = :hd
159
+ part.headers[md[1]] = md[2]
160
+ else
161
+ part.body_str << l
162
+ state = :bd
163
+ end
164
+
165
+ when :bd
166
+ part.body_str << l
167
+ end
168
+ end
169
+ part
170
+ end
171
+
172
+ def process_parts
173
+ full_batch_body = ''
174
+ @parts.each do |p|
175
+ next unless p.uri
176
+
177
+ batcha = MyOApp.new
178
+
179
+ batcha.full_req = @request
180
+
181
+ batcha.batch_call(p.batch_env)
182
+
183
+ full_batch_body << @mmboundary << "\r\n"
184
+ full_batch_body << batcha.batch_body
185
+ end
186
+ full_batch_body << @mmboundary << "--\r\n"
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env ruby
2
+ # Design: Collections are nothing more as Sequel based model classes that have
3
+ # somehow the character of an array (Enumerable)
4
+ # Thus Below we have called that "EntityClass". It's meant as "Collection"
5
+
6
+ require 'json'
7
+ require 'pp'
8
+ require 'pry'
9
+ require 'rexml/document'
10
+ require 'safrano_core.rb'
11
+ require 'odata/error.rb'
12
+ require 'odata/collection_filter.rb'
13
+ require 'odata/collection_order.rb'
14
+
15
+ # small helper method
16
+ # http://stackoverflow.com/
17
+ # questions/24980295/strictly-convert-string-to-integer-or-nil
18
+ def number_or_nil(str)
19
+ num = str.to_i
20
+ num if num.to_s == str
21
+ end
22
+
23
+ # another helper
24
+ # thanks https://stackoverflow.com/questions/1448670/ruby-stringto-class
25
+ class String
26
+ def constantize
27
+ names = split('::')
28
+ names.shift if names.empty? || names.first.empty?
29
+
30
+ const = Object
31
+ names.each do |name|
32
+ const = if const.const_defined?(name)
33
+ const.const_get(name)
34
+ else
35
+ const.const_missing(name)
36
+ end
37
+ end
38
+ const
39
+ end
40
+ end
41
+
42
+ module OData
43
+ # class methods. They Make heavy use of Sequel::Model functionality
44
+ # we will add this to our Model classes with "extend" --> self is the Class
45
+ module EntityClassBase
46
+ attr_reader :nav_collection_url_regexp
47
+ attr_reader :nav_entity_url_regexp
48
+ attr_reader :entity_id_url_regexp
49
+ attr_reader :attrib_paths_url_regexp
50
+ attr_reader :nav_collection_attribs
51
+ attr_reader :nav_entity_attribs
52
+ attr_reader :data_fields
53
+
54
+ attr_accessor :namespace
55
+
56
+ # dataset
57
+ attr_accessor :cx
58
+
59
+ # array of the objects --> dataset.to_a
60
+ attr_accessor :ax
61
+
62
+ # url params
63
+ attr_reader :params
64
+
65
+ # initialising block of code to be executed at end of
66
+ # ServerApp.publish_service after all model classes have been registered
67
+ # (without the associations/relationships)
68
+ # typically the block should contain the publication of the associations
69
+ attr_accessor :deferred_iblock
70
+
71
+ # convention: entityType is the Ruby Model class --> name is just to_s
72
+ def type_name
73
+ to_s
74
+ end
75
+
76
+ # convention: default for entity_set_name is the model table name
77
+ def entity_set_name
78
+ @entity_set_name = (@entity_set_name || table_name.to_s)
79
+ end
80
+
81
+ def execute_deferred_iblock
82
+ instance_eval { @deferred_iblock.call } if @deferred_iblock
83
+ end
84
+
85
+ # Factory json-> Model Object instance
86
+ def new_from_hson_h(hash)
87
+ enty = new
88
+ hash.delete('__metadata')
89
+ # DONE: move this somewhere else where it's evaluated only once at setup
90
+ #data_fields = db_schema.map do |col, cattr|
91
+ # cattr[:primary_key] ? nil : col
92
+ #end.select { |col| col }
93
+ enty.set_fields(hash, @data_fields, missing: :skip)
94
+ enty.save
95
+ enty
96
+ end
97
+
98
+ def odata_get_apply_filter_w_sequel
99
+ return unless @params['$filter']
100
+
101
+ # Sequel requires a dataset to build some sql expressions, so we
102
+ # need to pass one todo: use a dummy one ?
103
+ # @fi = Filter.new_by_parse(@params['$filter'], @cx)
104
+ return if @fi.parse_error?
105
+
106
+ @cx = @fi.apply_to_dataset(@cx)
107
+ @right_assocs.merge @fi.assocs
108
+ end
109
+
110
+ def odata_get_apply_order_w_sequel
111
+ return unless @params['$orderby']
112
+
113
+ fo = Order.new_by_parse(@params['$orderby'], @cx)
114
+ @cx = fo.apply_to_dataset(@cx)
115
+ @left_assocs.merge fo.assocs
116
+ end
117
+
118
+ def odata_get_do_assoc_joins_w_sequel
119
+ return if @right_assocs.empty? && @left_assocs.empty?
120
+
121
+ # Preparation: ensure that the left and right assocs sets are disjoint
122
+ # by keeping the duplicates in the left assoc set and removing
123
+ # them from the right assocs set. We can use Set difference...
124
+ @right_assocs -= @left_assocs
125
+
126
+ # this is for the filtering. Normally we can use inner join
127
+ @right_assocs.each { |aj| @cx = @cx.association_join(aj) }
128
+ # this is for the ordering.
129
+ # we need left join otherwise we could miss records sometimes
130
+ @left_assocs.each { |aj| @cx = @cx.association_left_join(aj) }
131
+ @cx = @cx.select_all(entity_set_name.to_sym)
132
+ end
133
+
134
+ def odata_get_apply_params_w_sequel
135
+ @left_assocs = Set.new
136
+ @right_assocs = Set.new
137
+ odata_get_apply_filter_w_sequel
138
+ odata_get_apply_order_w_sequel
139
+ odata_get_do_assoc_joins_w_sequel
140
+
141
+ @cx = @cx.offset(@params['$skip']) if @params['$skip']
142
+ @cx = @cx.limit(@params['$top']) if @params['$top']
143
+ @cx
144
+ end
145
+
146
+ def navigated_coll
147
+ false
148
+ end
149
+
150
+ def odata_get_apply_params
151
+ odata_get_apply_params_w_sequel
152
+ end
153
+
154
+ # url params validation methods.
155
+ # nil is the expected return for no errors
156
+ # an error class is returned in case of errors
157
+ # this way we can combine multiple validation calls with logical ||
158
+ def check_u_p_top
159
+ return unless @params['$top']
160
+
161
+ itop = number_or_nil(@params['$top'])
162
+ return BadRequestError if itop.nil? || itop.zero?
163
+ end
164
+
165
+ def check_u_p_skip
166
+ return unless @params['$skip']
167
+
168
+ iskip = number_or_nil(@params['$skip'])
169
+ return BadRequestError if iskip.nil? || (iskip < 0)
170
+ end
171
+
172
+ def check_u_p_filter
173
+ return unless @params['$filter']
174
+
175
+ # Sequel requires a dataset to build some sql expressions, so we
176
+ # need to pass one todo: use a dummy one ?
177
+ @fi = Filter.new_by_parse(@params['$filter'], @cx)
178
+ return BadRequestFilterParseError if @fi.parse_error?
179
+
180
+ # nil is the expected return for no errors
181
+ nil
182
+ end
183
+
184
+ def check_u_p_orderby
185
+ # TODO: this should be moved into OData::Order somehow, at least partly
186
+ return unless (pordlist = @params['$orderby'].dup)
187
+
188
+ pordlist.split(',').each do |pord|
189
+ pord.strip!
190
+ qualfn, dir = pord.split(/\s/)
191
+ qualfn.strip!
192
+ dir.strip! if dir
193
+ return BadRequestError unless @attribute_path_list.include? qualfn
194
+ return BadRequestError unless [nil, 'asc', 'desc'].include? dir
195
+ end
196
+ # nil is the expected return for no errors
197
+ nil
198
+ end
199
+
200
+ def build_attribute_path_list
201
+ @attribute_path_list = attribute_path_list
202
+ @attrib_paths_url_regexp = @attribute_path_list.join('|')
203
+ end
204
+
205
+ def attribute_path_list(nodes = Set.new)
206
+ # break circles
207
+ return [] if nodes.include?(entity_set_name)
208
+
209
+ ret = @columns.map(&:to_s)
210
+ nodes.add entity_set_name
211
+ if @nav_entity_attribs
212
+ @nav_entity_attribs.each do |a, k|
213
+ ret.concat(k.attribute_path_list(nodes).map { |kc| "#{a}/#{kc}" })
214
+ end
215
+ end
216
+ if @nav_collection_attribs
217
+ @nav_collection_attribs.each do |a, k|
218
+ ret.concat(k.attribute_path_list(nodes).map { |kc| "#{a}/#{kc}" })
219
+ end
220
+ end
221
+ ret
222
+ end
223
+
224
+ def check_url_params
225
+ return nil unless @params
226
+
227
+ check_u_p_top || check_u_p_skip || check_u_p_orderby || check_u_p_filter
228
+ end
229
+
230
+ def initialize_dataset
231
+ # initialize dataset
232
+ @cx = self
233
+ @ax = nil
234
+ @cx = navigated_dataset if @cx.navigated_coll
235
+ end
236
+
237
+ # on model class level we return the collection
238
+ def odata_get(req)
239
+ @params = req.params
240
+ @uribase = req.uribase
241
+ initialize_dataset
242
+
243
+ if (perr = check_url_params)
244
+ perr.odata_get(req)
245
+ else
246
+ odata_get_apply_params
247
+ if req.walker.do_count
248
+ [200, { 'Content-Type' => 'text/plain;charset=utf-8' },
249
+ @cx.count.to_s]
250
+ elsif req.accept?('application/json')
251
+ [200, { 'Content-Type' => 'application/json;charset=utf-8' },
252
+ to_odata_json(service: req.service)]
253
+ else # TODO: other formats
254
+ 406
255
+ end
256
+ end
257
+ end
258
+
259
+ def to_odata_json(service:)
260
+ expand = @params['$expand']
261
+ { 'd' => service.get_coll_odata_h(array: get_a,
262
+ expand: expand,
263
+ uribase: @uribase) }.to_json
264
+ end
265
+
266
+ def get_a
267
+ @ax.nil? ? @cx.to_a : @ax
268
+ end
269
+
270
+ def odata_post(req)
271
+ # TODO: check Request body format...
272
+ # TODO: this is for v2 only...
273
+ data = JSON.parse(req.body.read)['d']
274
+ if req.accept?('application/json')
275
+ [201, { 'Content-Type' => 'application/json;charset=utf-8' },
276
+ new_from_hson_h(data).to_odata_post_json(service: req.service)]
277
+ else # TODO: other formats
278
+ 415
279
+ end
280
+ end
281
+
282
+ # this functionally similar to the Sequel Rels (many_to_one etc)
283
+ # We need to base this on the Sequel rels, or extend them
284
+ def add_nav_prop_collection(assoc_symb, attr_name_str = nil)
285
+ @nav_collection_attribs = (@nav_collection_attribs || {})
286
+ # DONE: Error handling. This requires that associations
287
+ # have been properly defined with Sequel before
288
+ assoc = all_association_reflections.find do |a|
289
+ a[:name] == assoc_symb && a[:model] == self
290
+ end
291
+ unless assoc
292
+ raise OData::API::ModelAssociationNameError.new(self, assoc_symb)
293
+ end
294
+
295
+ attr_class = assoc[:class_name].constantize
296
+ lattr_name_str = (attr_name_str || assoc_symb.to_s)
297
+ @nav_collection_attribs[lattr_name_str] = attr_class
298
+ @nav_collection_url_regexp = @nav_collection_attribs.keys.join('|')
299
+ end
300
+
301
+ def add_nav_prop_single(assoc_symb, attr_name_str = nil)
302
+ @nav_entity_attribs = (@nav_entity_attribs || {})
303
+ # DONE: Error handling. This requires that associations
304
+ # have been properly defined with Sequel before
305
+ assoc = all_association_reflections.find do |a|
306
+ a[:name] == assoc_symb && a[:model] == self
307
+ end
308
+ unless assoc
309
+ raise OData::API::ModelAssociationNameError.new(self, assoc_symb)
310
+ end
311
+
312
+ attr_class = assoc[:class_name].constantize
313
+ lattr_name_str = (attr_name_str || assoc_symb.to_s)
314
+ @nav_entity_attribs[lattr_name_str] = attr_class
315
+ @nav_entity_url_regexp = @nav_entity_attribs.keys.join('|')
316
+ end
317
+
318
+ # old names...
319
+ # alias_method :add_nav_prop_collection, :addNavCollectionAttrib
320
+ # alias_method :add_nav_prop_single, :addNavEntityAttrib
321
+
322
+ def prepare_pk
323
+ if primary_key.is_a? Array
324
+ @pk_names = []
325
+ primary_key.each { |pk| @pk_names << pk.to_s }
326
+ # TODO: better handle quotes based on type
327
+ # (stringlike--> quote, int-like --> no quotes)
328
+
329
+ iuk = @pk_names.map { |pk| "#{pk}='?(\\w+)'?" }
330
+ @iuk_rgx = /\A#{iuk.join(',\s*')}\z/
331
+
332
+ iuk = @pk_names.map { |pk| "#{pk}='?\\w+'?" }
333
+ @entity_id_url_regexp = /\A\(\s*(#{iuk.join(',\s*')})\s*\)(.*)/
334
+ else
335
+ @pk_names = [primary_key.to_s]
336
+ @entity_id_url_regexp = /\A\('?([\w\s]+)'?\)(.*)/
337
+ end
338
+ end
339
+
340
+ def prepare_fields
341
+ @data_fields = db_schema.map do |col, cattr|
342
+ cattr[:primary_key] ? nil : col
343
+ end.select { |col| col }
344
+ end
345
+
346
+ # A regexp matching all allowed attributes of the Entity
347
+ # (eg ID|name|size etc... )
348
+ def attribute_url_regexp
349
+ # db_schema.map { |sch| sch[0] }.join('|')
350
+ # @columns is from Sequel Model
351
+ @columns.join('|')
352
+ end
353
+
354
+ # methods related to transitions to next state (cf. walker)
355
+ module Transitions
356
+ def transition_end(_match_result)
357
+ [nil, :end]
358
+ end
359
+
360
+ def transition_count(_match_result)
361
+ [self, :end_with_count]
362
+ end
363
+
364
+ def transition_id(match_result)
365
+ # binding.pry
366
+ if (id = match_result[1])
367
+ # puts "in transition_id, found #{y}"
368
+ if (y = find(id))
369
+ [y, :run]
370
+ else
371
+ [nil, :error, ErrorNotFound]
372
+ end
373
+ else
374
+ [nil, :error, ServerTransitionError]
375
+ end
376
+ end
377
+
378
+ def allowed_transitions
379
+ [Safrano::TransitionEnd,
380
+ Safrano::TransitionCount,
381
+ Safrano::Transition.new(entity_id_url_regexp,
382
+ trans: 'transition_id')]
383
+ end
384
+ end
385
+ include Transitions
386
+ end
387
+ # special handling for composite key
388
+ module EntityClassMultiPK
389
+ include EntityClassBase
390
+ # id is for composite key, something like fx='aas',fy_w='0001'
391
+ def find(mid)
392
+ # Note: @iuk_rgx is (needs to be) built on start with
393
+ # collklass.prepare_pk
394
+ md = @iuk_rgx.match(mid).to_a
395
+ md.shift # remove first element which is the whole match
396
+ self[*md] # Sequel Model rulez
397
+ end
398
+ end
399
+
400
+ # special handling for single key
401
+ module EntityClassSinglePK
402
+ include EntityClassBase
403
+ # id is really just the value of single pk
404
+ def find(id)
405
+ self[id]
406
+ end
407
+ end
408
+ end