safrano 0.4.0 → 0.4.5

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/core_ext/Dir/iter.rb +18 -0
  3. data/lib/core_ext/Hash/transform.rb +21 -0
  4. data/lib/core_ext/Integer/edm.rb +13 -0
  5. data/lib/core_ext/REXML/Document/output.rb +16 -0
  6. data/lib/core_ext/String/convert.rb +25 -0
  7. data/lib/core_ext/String/edm.rb +13 -0
  8. data/lib/core_ext/dir.rb +3 -0
  9. data/lib/core_ext/hash.rb +3 -0
  10. data/lib/core_ext/integer.rb +3 -0
  11. data/lib/core_ext/rexml.rb +3 -0
  12. data/lib/core_ext/string.rb +5 -0
  13. data/lib/odata/attribute.rb +15 -10
  14. data/lib/odata/batch.rb +15 -13
  15. data/lib/odata/collection.rb +144 -535
  16. data/lib/odata/collection_filter.rb +47 -40
  17. data/lib/odata/collection_media.rb +145 -74
  18. data/lib/odata/collection_order.rb +50 -37
  19. data/lib/odata/common_logger.rb +36 -34
  20. data/lib/odata/complex_type.rb +152 -0
  21. data/lib/odata/edm/primitive_types.rb +184 -0
  22. data/lib/odata/entity.rb +151 -197
  23. data/lib/odata/error.rb +175 -32
  24. data/lib/odata/expand.rb +126 -0
  25. data/lib/odata/filter/base.rb +74 -0
  26. data/lib/odata/filter/error.rb +49 -6
  27. data/lib/odata/filter/parse.rb +44 -36
  28. data/lib/odata/filter/sequel.rb +136 -67
  29. data/lib/odata/filter/sequel_function_adapter.rb +148 -0
  30. data/lib/odata/filter/token.rb +26 -19
  31. data/lib/odata/filter/tree.rb +113 -63
  32. data/lib/odata/function_import.rb +168 -0
  33. data/lib/odata/model_ext.rb +637 -0
  34. data/lib/odata/navigation_attribute.rb +44 -61
  35. data/lib/odata/relations.rb +5 -5
  36. data/lib/odata/select.rb +54 -0
  37. data/lib/odata/transition.rb +71 -0
  38. data/lib/odata/url_parameters.rb +128 -37
  39. data/lib/odata/walker.rb +19 -11
  40. data/lib/safrano.rb +17 -37
  41. data/lib/safrano/contract.rb +143 -0
  42. data/lib/safrano/core.rb +29 -104
  43. data/lib/safrano/core_ext.rb +13 -0
  44. data/lib/safrano/deprecation.rb +73 -0
  45. data/lib/safrano/multipart.rb +39 -43
  46. data/lib/safrano/rack_app.rb +68 -67
  47. data/lib/safrano/{odata_rack_builder.rb → rack_builder.rb} +18 -2
  48. data/lib/safrano/request.rb +102 -51
  49. data/lib/safrano/response.rb +5 -3
  50. data/lib/safrano/sequel_join_by_paths.rb +2 -2
  51. data/lib/safrano/service.rb +264 -220
  52. data/lib/safrano/version.rb +3 -1
  53. data/lib/sequel/plugins/join_by_paths.rb +17 -29
  54. metadata +34 -12
@@ -1,44 +1,26 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
4
  require 'rexml/document'
3
5
  require 'safrano.rb'
4
- require 'odata/collection.rb' # required for self.class.entity_type_name ??
6
+ require 'odata/model_ext.rb' # required for self.class.entity_type_name ??
5
7
  require_relative 'navigation_attribute'
6
8
 
7
- module OData
9
+ module Safrano
8
10
  # this will be mixed in the Model classes (subclasses of Sequel Model)
9
11
  module EntityBase
10
12
  attr_reader :params
11
- attr_reader :uribase
12
13
 
13
- include EntityBase::NavigationInfo
14
-
14
+ include Safrano::NavigationInfo
15
+
15
16
  # methods related to transitions to next state (cf. walker)
16
17
  module Transitions
17
18
  def allowed_transitions
18
- alltr = [
19
- Safrano::TransitionEnd,
20
- Safrano::TransitionCount,
21
- Safrano::TransitionLinks,
22
- Safrano::TransitionValue,
23
- Safrano::Transition.new(self.class.transition_attribute_regexp,
24
- trans: 'transition_attribute')
25
- ]
26
- if (ncurgx = self.class.nav_collection_url_regexp)
27
- alltr <<
28
- Safrano::Transition.new(%r{\A/(#{ncurgx})(.*)\z},
29
- trans: 'transition_nav_collection')
30
-
31
- end
32
- if (neurgx = self.class.nav_entity_url_regexp)
33
- alltr <<
34
- Safrano::Transition.new(%r{\A/(#{neurgx})(.*)\z},
35
- trans: 'transition_nav_entity')
36
- end
37
- alltr
19
+ self.class.entity_allowed_transitions
38
20
  end
39
21
 
40
22
  def transition_end(_match_result)
41
- [nil, :end]
23
+ Safrano::Transition::RESULT_END
42
24
  end
43
25
 
44
26
  def transition_count(_match_result)
@@ -56,8 +38,7 @@ module OData
56
38
 
57
39
  def transition_attribute(match_result)
58
40
  attrib = match_result[1]
59
- # [values[attrib.to_sym], :run]
60
- [OData::Attribute.new(self, attrib), :run]
41
+ [Safrano::Attribute.new(self, attrib), :run]
61
42
  end
62
43
 
63
44
  def transition_nav_collection(match_result)
@@ -69,17 +50,26 @@ module OData
69
50
  attrib = match_result[1]
70
51
  [get_related_entity(attrib), :run]
71
52
  end
53
+
54
+ def transition_invalid_attribute(match_result)
55
+ invalid_attrib = match_result[1]
56
+ [nil, :error, Safrano::ErrorNotFoundSegment.new(invalid_attrib)]
57
+ end
72
58
  end
73
59
 
74
60
  include Transitions
75
61
 
62
+ # for testing only?
63
+ def ==(other)
64
+ ((self.class.type_name == other.class.type_name) and (@values == other.values))
65
+ end
66
+
76
67
  def nav_values
77
68
  @nav_values = {}
78
69
 
79
70
  self.class.nav_entity_attribs&.each_key do |na_str|
80
71
  @nav_values[na_str.to_sym] = send(na_str)
81
72
  end
82
-
83
73
  @nav_values
84
74
  end
85
75
 
@@ -91,50 +81,42 @@ module OData
91
81
  @nav_coll
92
82
  end
93
83
 
94
- def uri(uriba)
95
- "#{uriba}/#{self.class.entity_set_name}(#{pk_uri})"
84
+ def uri
85
+ @odata_pk ||= "(#{pk_uri})"
86
+ "#{self.class.uri}#{@odata_pk}"
96
87
  end
88
+
97
89
  D = 'd'.freeze
98
- DJopen = '{"d":'.freeze
99
- DJclose = '}'.freeze
90
+ DJ_OPEN = '{"d":'.freeze
91
+ DJ_CLOSE = '}'.freeze
92
+
100
93
  # Json formatter for a single entity (probably OData V1/V2 like)
101
- def to_odata_json(service:)
102
- innerj = service.get_entity_odata_h(entity: self,
103
- expand: @params['$expand'],
104
- uribase: @uribase).to_json
105
- "#{DJopen}#{innerj}#{DJclose}"
94
+ def to_odata_json(request:)
95
+ template = self.class.output_template(expand_list: @uparms.expand.template,
96
+ select: @uparms.select)
97
+ innerj = request.service.get_entity_odata_h(entity: self,
98
+ template: template).to_json
99
+ "#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
106
100
  end
107
101
 
108
102
  # Json formatter for a single entity reached by navigation $links
109
103
  def to_odata_onelink_json(service:)
110
- innerj = service.get_entity_odata_link_h(entity: self,
111
- uribase: @uribase).to_json
112
- "#{DJopen}#{innerj}#{DJclose}"
104
+ innerj = service.get_entity_odata_link_h(entity: self).to_json
105
+ "#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
113
106
  end
114
107
 
115
-
116
- # needed for proper datetime output
117
- # TODO design/performance
118
- def casted_values
119
- # WARNING; this code is duplicated in attribute.rb
120
- # (and the inverted transformation is in test/client.rb)
121
- # will require a more systematic solution some day
122
- values_for_odata.transform_values! { |v|
123
- case v
124
- when Time
125
- # try to get back the database time zone and value
126
- (v + v.gmt_offset).utc.to_datetime
127
- else
128
- v
129
- end
130
- }
108
+ def selected_values_for_odata(cols)
109
+ allvals = values_for_odata
110
+ selvals = {}
111
+ cols.map(&:to_sym).each { |k| selvals[k] = allvals[k] if allvals.key?(k) }
112
+ selvals
131
113
  end
132
114
 
133
115
  # post paylod expects the new entity in an array
134
116
  def to_odata_post_json(service:)
135
117
  innerj = service.get_coll_odata_h(array: [self],
136
- uribase: @uribase).to_json
137
- "#{DJopen}#{innerj}#{DJclose}"
118
+ template: self.class.default_template).to_json
119
+ "#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
138
120
  end
139
121
 
140
122
  def type_name
@@ -143,32 +125,37 @@ module OData
143
125
 
144
126
  def copy_request_infos(req)
145
127
  @params = req.params
146
- @uribase = req.uribase
147
128
  @do_links = req.walker.do_links
129
+ @uparms = UrlParameters4Single.new(self, @params)
148
130
  end
149
131
 
150
- # Finally Process REST verbs...
151
- def odata_get(req)
152
- copy_request_infos(req)
153
-
132
+ def odata_get_output(req)
154
133
  if req.walker.media_value
155
134
  odata_media_value_get(req)
156
135
  elsif req.accept?(APPJSON)
136
+ # json is default content type so we dont need to specify it here again
157
137
  if req.walker.do_links
158
- [200, CT_JSON, [to_odata_onelink_json(service: req.service)]]
138
+ [200, EMPTY_HASH, [to_odata_onelink_json(service: req.service)]]
159
139
  else
160
- [200, CT_JSON, [to_odata_json(service: req.service)]]
140
+ [200, EMPTY_HASH, [to_odata_json(request: req)]]
161
141
  end
162
142
  else # TODO: other formats
163
143
  415
164
144
  end
165
145
  end
166
-
146
+
147
+ # Finally Process REST verbs...
148
+ def odata_get(req)
149
+ copy_request_infos(req)
150
+ @uparms.check_all.tap_valid { return odata_get_output(req) }
151
+ .tap_error { |e| return e.odata_get(req) }
152
+ end
153
+
167
154
  DELETE_REL_AND_ENTY = lambda do |entity, assoc, parent|
168
- OData.remove_nav_relation(entity, assoc, parent)
155
+ Safrano.remove_nav_relation(assoc, parent)
169
156
  entity.destroy(transaction: false)
170
157
  end
171
-
158
+
172
159
  def odata_delete_relation_and_entity(req, assoc, parent)
173
160
  if parent
174
161
  if req.in_changeset
@@ -182,16 +169,15 @@ module OData
182
169
  else
183
170
  destroy(transaction: false)
184
171
  end
185
- rescue StandardError => e
186
- raise SequelAdapterError.new(e)
172
+ rescue StandardError => e
173
+ raise SequelAdapterError.new(e)
187
174
  end
188
-
175
+
189
176
  # TODO: differentiate between POST/PUT/PATCH/MERGE
190
177
  def odata_post(req)
191
- data = JSON.parse(req.body.read)
192
- @uribase = req.uribase
193
-
194
- if req.accept?(APPJSON)
178
+ if req.walker.media_value
179
+ odata_media_value_put(req)
180
+ elsif req.accept?(APPJSON)
195
181
  data.delete('__metadata')
196
182
 
197
183
  if req.in_changeset
@@ -201,7 +187,7 @@ module OData
201
187
  update_fields(data, self.class.data_fields, missing: :skip)
202
188
  end
203
189
 
204
- [202, {}, to_odata_post_json(service: req.service)]
190
+ [202, EMPTY_HASH, to_odata_post_json(service: req.service)]
205
191
  else # TODO: other formats
206
192
  415
207
193
  end
@@ -210,23 +196,20 @@ module OData
210
196
  def odata_put(req)
211
197
  if req.walker.media_value
212
198
  odata_media_value_put(req)
213
- else
214
- if req.accept?(APPJSON)
215
- data = JSON.parse(req.body.read)
216
- @uribase = req.uribase
217
- data.delete('__metadata')
218
-
219
- if req.in_changeset
220
- set_fields(data, self.class.data_fields, missing: :skip)
221
- save(transaction: false)
222
- else
223
- update_fields(data, self.class.data_fields, missing: :skip)
224
- end
199
+ elsif req.accept?(APPJSON)
200
+ data = JSON.parse(req.body.read)
201
+ data.delete('__metadata')
225
202
 
226
- [204, {}, []]
227
- else # TODO: other formats
228
- 415
203
+ if req.in_changeset
204
+ set_fields(data, self.class.data_fields, missing: :skip)
205
+ save(transaction: false)
206
+ else
207
+ update_fields(data, self.class.data_fields, missing: :skip)
229
208
  end
209
+
210
+ ARY_204_EMPTY_HASH_ARY
211
+ else # TODO: other formats
212
+ 415
230
213
  end
231
214
  end
232
215
 
@@ -236,14 +219,12 @@ module OData
236
219
 
237
220
  # validate payload column names
238
221
  if (invalid = self.class.invalid_hash_data?(data))
239
- ::OData::Request::ON_CGST_ERROR.call(req)
240
- return [422, {}, ['Invalid attribute name: ', invalid.to_s]]
222
+ ::Safrano::Request::ON_CGST_ERROR.call(req)
223
+ return [422, EMPTY_HASH, ['Invalid attribute name: ', invalid.to_s]]
241
224
  end
242
225
  # TODO: check values/types
243
226
 
244
227
  my_data_fields = self.class.data_fields
245
- @uribase = req.uribase
246
- # if req.accept?('application/json')
247
228
 
248
229
  if req.in_changeset
249
230
  set_fields(data, my_data_fields, missing: :skip)
@@ -252,75 +233,17 @@ module OData
252
233
  update_fields(data, my_data_fields, missing: :skip)
253
234
  end
254
235
  # patch should return 204 + no content
255
- [204, {}, []]
236
+ ARY_204_EMPTY_HASH_ARY
256
237
  end
257
238
  end
258
239
 
259
- # redefinitions of the main methods for a navigated collection
260
- # (eg. all Books of Author[2] is Author[2].Books.all )
261
- module NavigationRedefinitions
262
- def all
263
- @child_method.call
264
- end
265
-
266
- def count
267
- @child_method.call.count
268
- end
269
-
270
- def dataset
271
- @child_dataset_method.call
272
- end
273
-
274
- def navigated_dataset
275
- @child_dataset_method.call
276
- end
277
-
278
- def each
279
- y = @child_method.call
280
- y.each { |enty| yield enty }
281
- end
282
-
283
- # TODO design... this is not DRY
284
- def slug_field
285
- superclass.slug_field
286
- end
287
-
288
- def type_name
289
- superclass.type_name
290
- end
291
-
292
- def media_handler
293
- superclass.media_handler
294
- end
295
-
296
- def to_a
297
- y = @child_method.call
298
- y.to_a
299
- end
300
- end
301
- # GetRelated that returns a anonymous Class (ie. representing a collection)
302
- # subtype of the related object Class ( childklass )
240
+ # GetRelated that returns a collection object representing
241
+ # wrapping the related object Class ( childklass )
303
242
  # (...to_many relationship )
304
243
  def get_related(childattrib)
305
- parent = self
306
- childklass = self.class.nav_collection_attribs[childattrib]
307
- Class.new(childklass) do
308
- # this makes use of Sequel's Model relationships; eg this is
309
- # 'Race[12].Edition'
310
- # where Race[12] would be our self and 'Edition' is the
311
- # childattrib(collection)
312
- @child_method = parent.method(childattrib.to_sym)
313
- @child_dataset_method = parent.method("#{childattrib}_dataset".to_sym)
314
- @nav_parent = parent
315
- @navattr_reflection = parent.class.association_reflections[childattrib.to_sym]
316
- prepare_pk
317
- prepare_fields
318
- # Now in this anonymous Class we can refine the "all, count and []
319
- # methods, to take into account the relationship
320
- extend NavigationRedefinitions
321
- end
244
+ Safrano::OData::NavigatedCollection.new(childattrib, self)
322
245
  end
323
-
246
+
324
247
  # GetRelatedEntity that returns an single related Entity
325
248
  # (...to_one relationship )
326
249
  def get_related_entity(childattrib)
@@ -333,35 +256,35 @@ module OData
333
256
  # then we return a Nil... wrapper object. This object then
334
257
  # allows to receive a POST operation that would actually create the nav attribute entity
335
258
 
336
- ret = method(childattrib.to_sym).call || OData::NilNavigationAttribute.new
337
-
259
+ ret = method(childattrib.to_sym).call || Safrano::NilNavigationAttribute.new
260
+
338
261
  ret.set_relation_info(self, childattrib)
339
-
262
+
340
263
  ret
341
264
  end
342
265
  end
343
- # end of module ODataEntity
266
+ # end of module SafranoEntity
344
267
  module Entity
345
268
  include EntityBase
346
269
  end
347
270
 
348
271
  module NonMediaEntity
349
272
  # non media entity metadata for json h
350
- def metadata_h(uribase:)
351
- { uri: uri(uribase),
273
+ def metadata_h
274
+ { uri: uri,
352
275
  type: type_name }
353
276
  end
354
277
 
355
278
  def values_for_odata
356
- values.dup
279
+ values
357
280
  end
358
281
 
359
282
  def odata_delete(req)
360
283
  if req.accept?(APPJSON)
361
- # delete
284
+ # delete
362
285
  begin
363
286
  odata_delete_relation_and_entity(req, @navattr_reflection, @nav_parent)
364
- [200, CT_JSON, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
287
+ [200, EMPTY_HASH, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
365
288
  rescue SequelAdapterError => e
366
289
  BadRequestSequelAdapterError.new(e).odata_get(req)
367
290
  end
@@ -371,40 +294,65 @@ module OData
371
294
  end
372
295
 
373
296
  # in case of a non media entity, we have to return an error on $value request
374
- def odata_media_value_get(req)
375
- return BadRequestNonMediaValue.odata_get
297
+ def odata_media_value_get(_req)
298
+ BadRequestNonMediaValue.odata_get
376
299
  end
377
300
 
378
301
  # in case of a non media entity, we have to return an error on $value PUT
379
- def odata_media_value_put(req)
380
- return BadRequestNonMediaValue.odata_get
302
+ def odata_media_value_put(_req)
303
+ BadRequestNonMediaValue.odata_get
304
+ end
305
+ end
306
+
307
+ module MappingBeforeOutput
308
+ # needed for proper datetime output
309
+ def casted_values(cols = nil)
310
+ vals = case cols
311
+ when nil
312
+ # we need to dup the model values as we need to change it before passing to_json,
313
+ # but we dont want to interfere with Sequel's owned data
314
+ # (eg because then in worst case it could happen that we write back changed values to DB)
315
+ values_for_odata.dup
316
+ else
317
+ selected_values_for_odata(cols)
318
+ end
319
+ self.class.time_cols.each { |tc| vals[tc] = vals[tc]&.iso8601 if vals.key?(tc) }
320
+ vals
321
+ end
322
+ end
323
+ module NoMappingBeforeOutput
324
+ # current model does not have eg. Time fields--> no special mapping, just to_json is fine
325
+ # --> we can use directly the model.values (values_for_odata) withoud dup'ing it as we dont
326
+ # need to change it, just output as is
327
+ def casted_values(cols = nil)
328
+ case cols
329
+ when nil
330
+ values_for_odata
331
+ else
332
+ selected_values_for_odata(cols)
333
+ end
381
334
  end
382
335
  end
383
336
 
384
337
  module MediaEntity
385
338
  # media entity metadata for json h
386
- def metadata_h(uribase:)
387
- { uri: uri(uribase),
339
+ def metadata_h
340
+ { uri: uri,
388
341
  type: type_name,
389
- media_src: media_src(uribase),
390
- edit_media: media_src(uribase),
342
+ media_src: media_src,
343
+ edit_media: edit_media,
391
344
  content_type: @values[:content_type] }
392
345
  end
393
346
 
394
- def media_src(urbase)
395
- "#{uri(urbase)}/$value"
347
+ def media_src
348
+ version = self.class.media_handler.ressource_version(self)
349
+ "#{uri}/$value?version=#{version}"
396
350
  end
397
351
 
398
- # directory where to put/find the media files for this entity-type
399
- def klass_dir
400
- type_name
352
+ def edit_media
353
+ "#{uri}/$value"
401
354
  end
402
355
 
403
- # # this is just ModelKlass/pk as a single string
404
- # def qualified_media_path_id
405
- # "#{self.class}/#{media_path_id}"
406
- # end
407
-
408
356
  def values_for_odata
409
357
  ret = values.dup
410
358
  ret.delete(:content_type)
@@ -416,13 +364,12 @@ module OData
416
364
  # delete the MR
417
365
  # delegate to the media handler on collection(ie class) level
418
366
  # TODO error handling
419
-
420
-
421
- self.class.media_handler.odata_delete(request: req, entity: self)
367
+
368
+ self.class.media_handler.odata_delete(entity: self)
422
369
  # delete the relation(s) to parent(s) (if any) and then entity
423
370
  odata_delete_relation_and_entity(req, @navattr_reflection, @nav_parent)
424
371
  # result
425
- [200, CT_JSON, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
372
+ [200, EMPTY_HASH, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
426
373
  else # TODO: other formats
427
374
  415
428
375
  end
@@ -438,17 +385,20 @@ module OData
438
385
  def odata_media_value_put(req)
439
386
  model = self.class
440
387
  req.with_media_data do |data, mimetype, filename|
441
- emdata = { :content_type => mimetype }
388
+ emdata = { content_type: mimetype }
442
389
  if req.in_changeset
443
390
  set_fields(emdata, model.data_fields, missing: :skip)
444
391
  save(transaction: false)
445
392
  else
393
+
446
394
  update_fields(emdata, model.data_fields, missing: :skip)
395
+
447
396
  end
448
397
  model.media_handler.replace_file(data: data,
449
398
  entity: self,
450
399
  filename: filename)
451
- [204, {}, []]
400
+
401
+ ARY_204_EMPTY_HASH_ARY
452
402
  end
453
403
  end
454
404
  end
@@ -463,6 +413,7 @@ module OData
463
413
  def media_path_id
464
414
  pk.to_s
465
415
  end
416
+
466
417
  def media_path_ids
467
418
  [pk]
468
419
  end
@@ -472,16 +423,19 @@ module OData
472
423
  module EntityMultiPK
473
424
  include Entity
474
425
  def pk_uri
475
- # pk_hash is provided by Sequel
476
- self.pk_hash.map { |k, v| "#{k}='#{v}'" }.join(',')
426
+ pku = +''
427
+ self.class.odata_upk_parts.each_with_index { |upart, i|
428
+ pku = "#{pku}#{upart}#{pk[i]}"
429
+ }
430
+ pku
477
431
  end
478
432
 
479
433
  def media_path_id
480
- self.pk_hash.values.join('_')
434
+ pk_hash.values.join(SPACE)
481
435
  end
482
-
436
+
483
437
  def media_path_ids
484
- self.pk_hash.values
438
+ pk_hash.values
485
439
  end
486
440
  end
487
441
  end