safrano 0.3.2 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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