safrano 0.3.2 → 0.4.2

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.
@@ -0,0 +1,59 @@
1
+ module Rack
2
+ class ODataCommonLogger < CommonLogger
3
+ def call(env)
4
+ env['safrano.logger_mw'] = self
5
+ super
6
+ end
7
+
8
+ # Handle https://github.com/rack/rack/pull/1526
9
+ # new in Rack 2.2.2 : Format has now 11 placeholders instead of 10
10
+
11
+ MSG_FUNC = if FORMAT.count('%') == 10
12
+ lambda { |env, length, status, began_at|
13
+ FORMAT % [
14
+ env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-',
15
+ env['REMOTE_USER'] || '-',
16
+ Time.now.strftime('%d/%b/%Y:%H:%M:%S %z'),
17
+ env[REQUEST_METHOD],
18
+ env[SCRIPT_NAME] + env[PATH_INFO],
19
+ env[QUERY_STRING].empty? ? '' : "?#{env[QUERY_STRING]}",
20
+ env[SERVER_PROTOCOL],
21
+ status.to_s[0..3],
22
+ length,
23
+ Utils.clock_time - began_at
24
+ ]
25
+ }
26
+ elsif FORMAT.count('%') == 11
27
+ lambda { |env, length, status, began_at|
28
+ FORMAT % [
29
+ env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-',
30
+ env['REMOTE_USER'] || '-',
31
+ Time.now.strftime('%d/%b/%Y:%H:%M:%S %z'),
32
+ env[REQUEST_METHOD],
33
+ env[SCRIPT_NAME],
34
+ env[PATH_INFO],
35
+ env[QUERY_STRING].empty? ? '' : "?#{env[QUERY_STRING]}",
36
+ env[SERVER_PROTOCOL],
37
+ status.to_s[0..3],
38
+ length,
39
+ Utils.clock_time - began_at
40
+ ]
41
+ }
42
+ end
43
+
44
+ def batch_log(env, status, header, began_at)
45
+ length = extract_content_length(header)
46
+
47
+ msg = MSG_FUNC.call(env, length, status, began_at)
48
+
49
+ logger = @logger || env[RACK_ERRORS]
50
+ # Standard library logger doesn't support write but it supports << which actually
51
+ # calls to write on the log device without formatting
52
+ if logger.respond_to?(:write)
53
+ logger.write(msg)
54
+ else
55
+ logger << msg
56
+ end
57
+ end
58
+ end
59
+ end
@@ -2,13 +2,16 @@ require 'json'
2
2
  require 'rexml/document'
3
3
  require 'safrano.rb'
4
4
  require 'odata/collection.rb' # required for self.class.entity_type_name ??
5
+ require_relative 'navigation_attribute'
5
6
 
6
7
  module OData
7
8
  # this will be mixed in the Model classes (subclasses of Sequel Model)
8
- module Entity
9
+ module EntityBase
9
10
  attr_reader :params
10
11
  attr_reader :uribase
11
12
 
13
+ include EntityBase::NavigationInfo
14
+
12
15
  # methods related to transitions to next state (cf. walker)
13
16
  module Transitions
14
17
  def allowed_transitions
@@ -16,6 +19,7 @@ module OData
16
19
  Safrano::TransitionEnd,
17
20
  Safrano::TransitionCount,
18
21
  Safrano::TransitionLinks,
22
+ Safrano::TransitionValue,
19
23
  Safrano::Transition.new(self.class.transition_attribute_regexp,
20
24
  trans: 'transition_attribute')
21
25
  ]
@@ -41,6 +45,11 @@ module OData
41
45
  [self, :end]
42
46
  end
43
47
 
48
+ def transition_value(_match_result)
49
+ # $value is only allowd for media entities (or attributes)
50
+ [self, :end_with_media_value]
51
+ end
52
+
44
53
  def transition_links(_match_result)
45
54
  [self, :run_with_links]
46
55
  end
@@ -54,8 +63,6 @@ module OData
54
63
  def transition_nav_collection(match_result)
55
64
  attrib = match_result[1]
56
65
  [get_related(attrib), :run]
57
- # attr_klass = self.class.nav_collection_attribs[attrib].to_s
58
- # [get_related(attr_klass), :run]
59
66
  end
60
67
 
61
68
  def transition_nav_entity(match_result)
@@ -72,7 +79,6 @@ module OData
72
79
  self.class.nav_entity_attribs&.each_key do |na_str|
73
80
  @nav_values[na_str.to_sym] = send(na_str)
74
81
  end
75
-
76
82
  @nav_values
77
83
  end
78
84
 
@@ -84,30 +90,51 @@ module OData
84
90
  @nav_coll
85
91
  end
86
92
 
87
- def uri_path
88
- kla = self.class
89
- "#{kla.entity_set_name}(#{pk_uri})"
90
- end
91
-
92
93
  def uri(uriba)
93
- "#{uriba}/#{uri_path}"
94
+ "#{uriba}/#{self.class.entity_set_name}(#{pk_uri})"
94
95
  end
96
+ D = 'd'.freeze
97
+ DJ_OPEN = '{"d":'.freeze
98
+ DJ_CLOSE = '}'.freeze
95
99
 
96
100
  # Json formatter for a single entity (probably OData V1/V2 like)
97
101
  def to_odata_json(service:)
98
- { 'd' => service.get_entity_odata_h(entity: self,
99
- expand: @params['$expand'],
100
- # links: @do_links,
101
- uribase: @uribase) }.to_json
102
+ template = self.class.output_template(@uparms)
103
+ innerj = service.get_entity_odata_h(entity: self,
104
+ template: template,
105
+ uribase: @uribase).to_json
106
+ "#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
107
+ end
108
+
109
+ # Json formatter for a single entity reached by navigation $links
110
+ def to_odata_onelink_json(service:)
111
+ innerj = service.get_entity_odata_link_h(entity: self,
112
+ uribase: @uribase).to_json
113
+ "#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
114
+ end
115
+
116
+ def selected_values_for_odata(cols)
117
+ allvals = values_for_odata
118
+ selvals = {}
119
+ cols.map(&:to_sym).each { |k| selvals[k] = allvals[k] if allvals.key?(k) }
120
+ selvals
102
121
  end
103
122
 
104
123
  # needed for proper datetime output
105
- # TODO design/performance
106
- def casted_values
124
+ # TODO: design/performance
125
+ def casted_values(cols = nil)
126
+ vals = case cols
127
+ when nil
128
+ values_for_odata
129
+ else
130
+ selected_values_for_odata(cols)
131
+ end
132
+
107
133
  # WARNING; this code is duplicated in attribute.rb
108
134
  # (and the inverted transformation is in test/client.rb)
109
135
  # will require a more systematic solution some day
110
- values.transform_values! { |v|
136
+
137
+ vals.transform_values! do |v|
111
138
  case v
112
139
  when Time
113
140
  # try to get back the database time zone and value
@@ -115,56 +142,92 @@ module OData
115
142
  else
116
143
  v
117
144
  end
118
- }
145
+ end
119
146
  end
120
147
 
121
148
  # post paylod expects the new entity in an array
122
149
  def to_odata_post_json(service:)
123
- { 'd' => service.get_coll_odata_h(array: [self],
124
- uribase: @uribase) }.to_json
150
+ innerj = service.get_coll_odata_h(array: [self],
151
+ template: self.class.default_template,
152
+ uribase: @uribase).to_json
153
+ "#{DJ_OPEN}#{innerj}#{DJ_CLOSE}"
125
154
  end
126
155
 
127
156
  def type_name
128
157
  self.class.type_name
129
158
  end
130
159
 
131
- # metadata for json h
132
- def metadata_h(uribase:)
133
- { uri: uri(uribase),
134
- type: type_name }
135
- end
136
-
137
160
  def copy_request_infos(req)
138
161
  @params = req.params
139
162
  @uribase = req.uribase
140
163
  @do_links = req.walker.do_links
164
+ @uparms = UrlParameters4Single.new(@params)
141
165
  end
142
166
 
143
167
  # Finally Process REST verbs...
144
168
  def odata_get(req)
145
169
  copy_request_infos(req)
146
-
147
- if req.accept?(APPJSON)
148
- [200, CT_JSON, to_odata_json(service: req.service)]
170
+ if req.walker.media_value
171
+ odata_media_value_get(req)
172
+ elsif req.accept?(APPJSON)
173
+ if req.walker.do_links
174
+ [200, CT_JSON, [to_odata_onelink_json(service: req.service)]]
175
+ else
176
+ [200, CT_JSON, [to_odata_json(service: req.service)]]
177
+ end
149
178
  else # TODO: other formats
150
179
  415
151
180
  end
152
181
  end
153
182
 
154
- def odata_delete(req)
155
- if req.accept?(APPJSON)
156
- delete
157
- [200, CT_JSON, { 'd' => req.service.get_emptycoll_odata_h }.to_json]
158
- else # TODO: other formats
159
- 415
183
+ DELETE_REL_AND_ENTY = lambda do |entity, assoc, parent|
184
+ OData.remove_nav_relation(assoc, parent)
185
+ entity.destroy(transaction: false)
186
+ end
187
+
188
+ def odata_delete_relation_and_entity(req, assoc, parent)
189
+ if parent
190
+ if req.in_changeset
191
+ # in-changeset requests get their own transaction
192
+ DELETE_REL_AND_ENTY.call(self, assoc, parent)
193
+ else
194
+ db.transaction do
195
+ DELETE_REL_AND_ENTY.call(self, assoc, parent)
196
+ end
197
+ end
198
+ else
199
+ destroy(transaction: false)
160
200
  end
201
+ rescue StandardError => e
202
+ raise SequelAdapterError.new(e)
161
203
  end
162
204
 
205
+ # TODO: differentiate between POST/PUT/PATCH/MERGE
163
206
  def odata_post(req)
164
- data = JSON.parse(req.body.read)
165
- @uribase = req.uribase
207
+ if req.walker.media_value
208
+ odata_media_value_put(req)
209
+ elsif req.accept?(APPJSON)
210
+ data.delete('__metadata')
166
211
 
167
- if req.accept?(APPJSON)
212
+ if req.in_changeset
213
+ set_fields(data, self.class.data_fields, missing: :skip)
214
+ save(transaction: false)
215
+ else
216
+ update_fields(data, self.class.data_fields, missing: :skip)
217
+ end
218
+
219
+ [202, EMPTY_HASH, to_odata_post_json(service: req.service)]
220
+ else # TODO: other formats
221
+ 415
222
+ end
223
+ end
224
+
225
+ def odata_put(req)
226
+ if req.walker.media_value
227
+ odata_media_value_put(req)
228
+ elsif req.accept?(APPJSON)
229
+ data = JSON.parse(req.body.read)
230
+ @uribase = req.uribase
168
231
  data.delete('__metadata')
169
232
 
170
233
  if req.in_changeset
@@ -174,21 +237,20 @@ module OData
174
237
  update_fields(data, self.class.data_fields, missing: :skip)
175
238
  end
176
239
 
177
- [202, to_odata_post_json(service: req.service)]
240
+ ARY_204_EMPTY_HASH_ARY
178
241
  else # TODO: other formats
179
242
  415
180
243
  end
181
244
  end
182
245
 
183
246
  def odata_patch(req)
184
- on_error = (proc { raise Sequel::Rollback } if req.in_changeset)
185
- req.with_parsed_data(on_error: on_error) do |data|
247
+ req.with_parsed_data do |data|
186
248
  data.delete('__metadata')
187
249
 
188
250
  # validate payload column names
189
251
  if (invalid = self.class.invalid_hash_data?(data))
190
- on_error.call if on_error
191
- return [422, {}, ['Invalid attribute name: ', invalid.to_s]]
252
+ ::OData::Request::ON_CGST_ERROR.call(req)
253
+ return [422, EMPTY_HASH, ['Invalid attribute name: ', invalid.to_s]]
192
254
  end
193
255
  # TODO: check values/types
194
256
 
@@ -203,7 +265,7 @@ module OData
203
265
  update_fields(data, my_data_fields, missing: :skip)
204
266
  end
205
267
  # patch should return 204 + no content
206
- [204, {}, []]
268
+ ARY_204_EMPTY_HASH_ARY
207
269
  end
208
270
  end
209
271
 
@@ -218,11 +280,6 @@ module OData
218
280
  @child_method.call.count
219
281
  end
220
282
 
221
- # TODO: fix design
222
- def navigated_coll
223
- true
224
- end
225
-
226
283
  def dataset
227
284
  @child_dataset_method.call
228
285
  end
@@ -236,6 +293,23 @@ module OData
236
293
  y.each { |enty| yield enty }
237
294
  end
238
295
 
296
+ # TODO: design... this is not DRY
297
+ def slug_field
298
+ superclass.slug_field
299
+ end
300
+
301
+ def type_name
302
+ superclass.type_name
303
+ end
304
+
305
+ def media_handler
306
+ superclass.media_handler
307
+ end
308
+
309
+ def default_template
310
+ superclass.default_template
311
+ end
312
+
239
313
  def to_a
240
314
  y = @child_method.call
241
315
  y.to_a
@@ -254,6 +328,8 @@ module OData
254
328
  # childattrib(collection)
255
329
  @child_method = parent.method(childattrib.to_sym)
256
330
  @child_dataset_method = parent.method("#{childattrib}_dataset".to_sym)
331
+ @nav_parent = parent
332
+ @navattr_reflection = parent.class.association_reflections[childattrib.to_sym]
257
333
  prepare_pk
258
334
  prepare_fields
259
335
  # Now in this anonymous Class we can refine the "all, count and []
@@ -269,11 +345,134 @@ module OData
269
345
  # 'Race[12].RaceType'
270
346
  # where Race[12] would be our self and 'RaceType' is the single
271
347
  # childattrib entity
272
- child_method = method(childattrib.to_sym)
273
- child_method.call
348
+
349
+ # when the child attribute is nil (because the FK is NULL on DB)
350
+ # then we return a Nil... wrapper object. This object then
351
+ # allows to receive a POST operation that would actually create the nav attribute entity
352
+
353
+ ret = method(childattrib.to_sym).call || OData::NilNavigationAttribute.new
354
+
355
+ ret.set_relation_info(self, childattrib)
356
+
357
+ ret
274
358
  end
275
359
  end
276
360
  # end of module ODataEntity
361
+ module Entity
362
+ include EntityBase
363
+ end
364
+
365
+ module NonMediaEntity
366
+ # non media entity metadata for json h
367
+ def metadata_h(uribase:)
368
+ { uri: uri(uribase),
369
+ type: type_name }
370
+ end
371
+
372
+ def values_for_odata
373
+ values.dup
374
+ end
375
+
376
+ def odata_delete(req)
377
+ if req.accept?(APPJSON)
378
+ # delete
379
+ begin
380
+ odata_delete_relation_and_entity(req, @navattr_reflection, @nav_parent)
381
+ [200, CT_JSON, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
382
+ rescue SequelAdapterError => e
383
+ BadRequestSequelAdapterError.new(e).odata_get(req)
384
+ end
385
+ else # TODO: other formats
386
+ 415
387
+ end
388
+ end
389
+
390
+ # in case of a non media entity, we have to return an error on $value request
391
+ def odata_media_value_get(_req)
392
+ BadRequestNonMediaValue.odata_get
393
+ end
394
+
395
+ # in case of a non media entity, we have to return an error on $value PUT
396
+ def odata_media_value_put(_req)
397
+ BadRequestNonMediaValue.odata_get
398
+ end
399
+ end
400
+
401
+ module MediaEntity
402
+ # media entity metadata for json h
403
+ def metadata_h(uribase:)
404
+ { uri: uri(uribase),
405
+ type: type_name,
406
+ media_src: media_src(uribase),
407
+ edit_media: edit_media(uribase),
408
+ content_type: @values[:content_type] }
409
+ end
410
+
411
+ def media_src(uribase)
412
+ version = self.class.media_handler.ressource_version(self)
413
+ "#{uri(uribase)}/$value?version=#{version}"
414
+ end
415
+
416
+ def edit_media(uribase)
417
+ "#{uri(uribase)}/$value"
418
+ end
419
+
420
+ # directory where to put/find the media files for this entity-type
421
+ def klass_dir
422
+ type_name
423
+ end
424
+
425
+ # # this is just ModelKlass/pk as a single string
426
+ # def qualified_media_path_id
427
+ # "#{self.class}/#{media_path_id}"
428
+ # end
429
+
430
+ def values_for_odata
431
+ ret = values.dup
432
+ ret.delete(:content_type)
433
+ ret
434
+ end
435
+
436
+ def odata_delete(req)
437
+ if req.accept?(APPJSON)
438
+ # delete the MR
439
+ # delegate to the media handler on collection(ie class) level
440
+ # TODO error handling
441
+
442
+ self.class.media_handler.odata_delete(entity: self)
443
+ # delete the relation(s) to parent(s) (if any) and then entity
444
+ odata_delete_relation_and_entity(req, @navattr_reflection, @nav_parent)
445
+ # result
446
+ [200, CT_JSON, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
447
+ else # TODO: other formats
448
+ 415
449
+ end
450
+ end
451
+
452
+ # real implementation for returning $value for a media entity
453
+ def odata_media_value_get(req)
454
+ # delegate to the media handler on collection(ie class) level
455
+ self.class.media_handler.odata_get(request: req, entity: self)
456
+ end
457
+
458
+ # real implementation for replacing $value for a media entity
459
+ def odata_media_value_put(req)
460
+ model = self.class
461
+ req.with_media_data do |data, mimetype, filename|
462
+ emdata = { content_type: mimetype }
463
+ if req.in_changeset
464
+ set_fields(emdata, model.data_fields, missing: :skip)
465
+ save(transaction: false)
466
+ else
467
+ update_fields(emdata, model.data_fields, missing: :skip)
468
+ end
469
+ model.media_handler.replace_file(data: data,
470
+ entity: self,
471
+ filename: filename)
472
+ ARY_204_EMPTY_HASH_ARY
473
+ end
474
+ end
475
+ end
277
476
 
278
477
  # for a single public key
279
478
  module EntitySinglePK
@@ -281,15 +480,30 @@ module OData
281
480
  def pk_uri
282
481
  pk
283
482
  end
483
+
484
+ def media_path_id
485
+ pk.to_s
486
+ end
487
+
488
+ def media_path_ids
489
+ [pk]
490
+ end
284
491
  end
285
492
 
286
493
  # for multiple key
287
494
  module EntityMultiPK
288
495
  include Entity
289
496
  def pk_uri
290
- # self.class.primary_key is provided by Sequel as
291
- # array of symbols
292
- self.class.primary_key.map { |pk| "#{pk}='#{values[pk]}'" }.join(',')
497
+ # pk_hash is provided by Sequel
498
+ pk_hash.map { |k, v| "#{k}='#{v}'" }.join(COMMA)
499
+ end
500
+
501
+ def media_path_id
502
+ pk_hash.values.join(SPACE)
503
+ end
504
+
505
+ def media_path_ids
506
+ pk_hash.values
293
507
  end
294
508
  end
295
509
  end