safrano 0.3.2 → 0.3.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,148 @@
1
+ require 'rack'
2
+ require_relative './navigation_attribute.rb'
3
+
4
+ module OData
5
+ module Media
6
+ # base class for Media Handler
7
+ class Handler
8
+ end
9
+
10
+ # Simple static File/Directory based media store handler
11
+ # similar to Rack::Static
12
+ class Static < Handler
13
+ def initialize(root: nil)
14
+ @root = File.absolute_path(root || Dir.pwd)
15
+ @file_server = ::Rack::File.new(@root)
16
+ end
17
+
18
+ # minimal working implementation...
19
+ # Note: @file_server works relative to @root directory
20
+ def odata_get(request:, entity:)
21
+ media_env = request.env.dup
22
+ relpath = Dir.chdir(abs_path(entity)) do
23
+ # simple design: one file per directory, and the directory
24
+ # contains the media entity-id --> implicit link between the media
25
+ # entity
26
+ filename = Dir.glob('*').first
27
+
28
+ File.join(path(entity), filename)
29
+ end
30
+ media_env['PATH_INFO'] = relpath
31
+ @file_server.call(media_env)
32
+ end
33
+
34
+ # TODO perf: this can be precalculated and cached on MediaModelKlass level
35
+ # and passed as argument to save_file
36
+ def abs_klass_dir(entity)
37
+ File.absolute_path(entity.klass_dir, @root)
38
+ end
39
+
40
+ def abs_path(entity)
41
+ File.absolute_path(path(entity), @root)
42
+ end
43
+
44
+ # this is relative to @root
45
+ def path(entity)
46
+ File.join(entity.klass_dir, entity.media_path_id)
47
+ end
48
+
49
+ # Here as well, MVP implementation
50
+ def save_file(data:, filename:, entity:)
51
+ mpi = entity.media_path_id
52
+ Dir.chdir(abs_klass_dir(entity)) do
53
+ Dir.mkdir mpi unless Dir.exists?(mpi)
54
+ Dir.chdir mpi do
55
+ File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
56
+ end
57
+ end
58
+ end
59
+
60
+ # Here as well, MVP implementation
61
+ def replace_file(data:, filename:, entity:)
62
+ mpi = entity.media_path_id
63
+ Dir.chdir(abs_klass_dir(entity)) do
64
+ Dir.mkdir mpi unless Dir.exists?(mpi)
65
+ Dir.chdir mpi do
66
+ Dir.glob('*').each { |oldf| File.delete(oldf) }
67
+ File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ # special handling for media entity
75
+ module EntityClassMedia
76
+ attr_reader :media_handler
77
+
78
+ # API method for defining the media handler
79
+ # eg.
80
+ # publish_media_model photos do
81
+ # use OData::Media::Static, :root => '/media_root'
82
+ # end
83
+
84
+ def set_default_media_handler
85
+ @media_handler = OData::Media::Static.new
86
+ end
87
+
88
+ def use(klass, *args)
89
+ @media_handler = klass.new(*args)
90
+ end
91
+
92
+ def api_check_media_fields
93
+ unless self.db_schema.has_key?(:content_type)
94
+ raise OData::API::MediaModelError, self
95
+ end
96
+ # unless self.db_schema.has_key?(:media_src)
97
+ # raise OData::API::MediaModelError, self
98
+ # end
99
+ end
100
+
101
+ def new_media_entity(mimetype:)
102
+ nh = {}
103
+ nh['content_type'] = mimetype
104
+ new_from_hson_h(nh)
105
+ end
106
+
107
+ # POST for media entity collection --> Create media-entity linked to
108
+ # uploaded file from payload
109
+ # 1. create new media entity
110
+ # 2. get the pk/id from the new media entity
111
+ # 3. Upload the file and use the pk/id to get an unique Directory/filename
112
+ # assignment to the media entity record
113
+ # NOTE: we will implement this first in a MVP way. There will be plenty of
114
+ # potential future enhancements (performance, scallability, flexibility... with added complexity)
115
+ # 4. create relation to parent if needed
116
+ def odata_create_entity_and_relation(req, assoc, parent)
117
+ req.with_media_data do |data, mimetype, filename|
118
+ ## future enhancement: validate allowed mimetypes ?
119
+ # if (invalid = invalid_media_mimetype(mimetype))
120
+ # ::OData::Request::ON_CGST_ERROR.call(req)
121
+ # return [422, {}, ['Invalid mime type: ', invalid.to_s]]
122
+ # end
123
+
124
+ if req.accept?(APPJSON)
125
+
126
+ new_entity = new_media_entity(mimetype: mimetype)
127
+
128
+ # to_one rels are create with FK data set on the parent entity
129
+ if parent
130
+ odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
131
+ else
132
+ # in-changeset requests get their own transaction
133
+ new_entity.save(transaction: !req.in_changeset)
134
+ end
135
+
136
+ req.register_content_id_ref(new_entity)
137
+ new_entity.copy_request_infos(req)
138
+
139
+ media_handler.save_file(data: data, entity: new_entity, filename: filename)
140
+
141
+ [201, CT_JSON, new_entity.to_odata_post_json(service: req.service)]
142
+ else # TODO: other formats
143
+ 415
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,34 @@
1
+ module Rack
2
+ class ODataCommonLogger < CommonLogger
3
+ def call(env)
4
+ env['safrano.logger_mw'] = self
5
+ super
6
+ end
7
+
8
+ def batch_log(env, status, header, began_at)
9
+ length = extract_content_length(header)
10
+
11
+ msg = FORMAT % [
12
+ env['HTTP_X_FORWARDED_FOR'] || env["REMOTE_ADDR"] || "-",
13
+ env["REMOTE_USER"] || "-",
14
+ Time.now.strftime("%d/%b/%Y:%H:%M:%S %z"),
15
+ env[REQUEST_METHOD],
16
+ env[PATH_INFO],
17
+ env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}",
18
+ env[SERVER_PROTOCOL],
19
+ status.to_s[0..3],
20
+ length,
21
+ Utils.clock_time - began_at
22
+ ]
23
+
24
+ logger = @logger || env[RACK_ERRORS]
25
+ # Standard library logger doesn't support write but it supports << which actually
26
+ # calls to write on the log device without formatting
27
+ if logger.respond_to?(:write)
28
+ logger.write(msg)
29
+ else
30
+ logger << msg
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/odata/entity.rb CHANGED
@@ -5,7 +5,7 @@ require 'odata/collection.rb' # required for self.class.entity_type_name ??
5
5
 
6
6
  module OData
7
7
  # this will be mixed in the Model classes (subclasses of Sequel Model)
8
- module Entity
8
+ module EntityBase
9
9
  attr_reader :params
10
10
  attr_reader :uribase
11
11
 
@@ -16,6 +16,7 @@ module OData
16
16
  Safrano::TransitionEnd,
17
17
  Safrano::TransitionCount,
18
18
  Safrano::TransitionLinks,
19
+ Safrano::TransitionValue,
19
20
  Safrano::Transition.new(self.class.transition_attribute_regexp,
20
21
  trans: 'transition_attribute')
21
22
  ]
@@ -41,6 +42,11 @@ module OData
41
42
  [self, :end]
42
43
  end
43
44
 
45
+ def transition_value(_match_result)
46
+ # $value is only allowd for media entities (or attributes)
47
+ [self, :end_with_media_value]
48
+ end
49
+
44
50
  def transition_links(_match_result)
45
51
  [self, :run_with_links]
46
52
  end
@@ -54,8 +60,6 @@ module OData
54
60
  def transition_nav_collection(match_result)
55
61
  attrib = match_result[1]
56
62
  [get_related(attrib), :run]
57
- # attr_klass = self.class.nav_collection_attribs[attrib].to_s
58
- # [get_related(attr_klass), :run]
59
63
  end
60
64
 
61
65
  def transition_nav_entity(match_result)
@@ -84,21 +88,19 @@ module OData
84
88
  @nav_coll
85
89
  end
86
90
 
87
- def uri_path
88
- kla = self.class
89
- "#{kla.entity_set_name}(#{pk_uri})"
90
- end
91
-
92
91
  def uri(uriba)
93
- "#{uriba}/#{uri_path}"
92
+ "#{uriba}/#{self.class.entity_set_name}(#{pk_uri})"
94
93
  end
95
-
94
+ D = 'd'.freeze
95
+ DJopen = '{"d":'.freeze
96
+ DJclose = '}'.freeze
96
97
  # Json formatter for a single entity (probably OData V1/V2 like)
97
98
  def to_odata_json(service:)
98
- { 'd' => service.get_entity_odata_h(entity: self,
99
+ innerj = service.get_entity_odata_h(entity: self,
99
100
  expand: @params['$expand'],
100
101
  # links: @do_links,
101
- uribase: @uribase) }.to_json
102
+ uribase: @uribase).to_json
103
+ "#{DJopen}#{innerj}#{DJclose}"
102
104
  end
103
105
 
104
106
  # needed for proper datetime output
@@ -107,7 +109,7 @@ module OData
107
109
  # WARNING; this code is duplicated in attribute.rb
108
110
  # (and the inverted transformation is in test/client.rb)
109
111
  # will require a more systematic solution some day
110
- values.transform_values! { |v|
112
+ values_for_odata.transform_values! { |v|
111
113
  case v
112
114
  when Time
113
115
  # try to get back the database time zone and value
@@ -120,20 +122,15 @@ module OData
120
122
 
121
123
  # post paylod expects the new entity in an array
122
124
  def to_odata_post_json(service:)
123
- { 'd' => service.get_coll_odata_h(array: [self],
124
- uribase: @uribase) }.to_json
125
+ innerj = service.get_coll_odata_h(array: [self],
126
+ uribase: @uribase).to_json
127
+ "#{DJopen}#{innerj}#{DJclose}"
125
128
  end
126
129
 
127
130
  def type_name
128
131
  self.class.type_name
129
132
  end
130
133
 
131
- # metadata for json h
132
- def metadata_h(uribase:)
133
- { uri: uri(uribase),
134
- type: type_name }
135
- end
136
-
137
134
  def copy_request_infos(req)
138
135
  @params = req.params
139
136
  @uribase = req.uribase
@@ -144,8 +141,10 @@ module OData
144
141
  def odata_get(req)
145
142
  copy_request_infos(req)
146
143
 
147
- if req.accept?(APPJSON)
148
- [200, CT_JSON, to_odata_json(service: req.service)]
144
+ if req.walker.media_value
145
+ odata_media_value_get(req)
146
+ elsif req.accept?(APPJSON)
147
+ [200, CT_JSON, [to_odata_json(service: req.service)]]
149
148
  else # TODO: other formats
150
149
  415
151
150
  end
@@ -154,12 +153,13 @@ module OData
154
153
  def odata_delete(req)
155
154
  if req.accept?(APPJSON)
156
155
  delete
157
- [200, CT_JSON, { 'd' => req.service.get_emptycoll_odata_h }.to_json]
156
+ [200, CT_JSON, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
158
157
  else # TODO: other formats
159
158
  415
160
159
  end
161
160
  end
162
161
 
162
+ # TODO: differentiate between POST/PUT/PATCH/MERGE
163
163
  def odata_post(req)
164
164
  data = JSON.parse(req.body.read)
165
165
  @uribase = req.uribase
@@ -174,20 +174,42 @@ module OData
174
174
  update_fields(data, self.class.data_fields, missing: :skip)
175
175
  end
176
176
 
177
- [202, to_odata_post_json(service: req.service)]
177
+ [202, {}, to_odata_post_json(service: req.service)]
178
178
  else # TODO: other formats
179
179
  415
180
180
  end
181
181
  end
182
182
 
183
+ def odata_put(req)
184
+ if req.walker.media_value
185
+ odata_media_value_put(req)
186
+ else
187
+ if req.accept?(APPJSON)
188
+ data = JSON.parse(req.body.read)
189
+ @uribase = req.uribase
190
+ data.delete('__metadata')
191
+
192
+ if req.in_changeset
193
+ set_fields(data, self.class.data_fields, missing: :skip)
194
+ save(transaction: false)
195
+ else
196
+ update_fields(data, self.class.data_fields, missing: :skip)
197
+ end
198
+
199
+ [204, {}, []]
200
+ else # TODO: other formats
201
+ 415
202
+ end
203
+ end
204
+ end
205
+
183
206
  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|
207
+ req.with_parsed_data do |data|
186
208
  data.delete('__metadata')
187
209
 
188
210
  # validate payload column names
189
211
  if (invalid = self.class.invalid_hash_data?(data))
190
- on_error.call if on_error
212
+ ::OData::Request::ON_CGST_ERROR.call(req)
191
213
  return [422, {}, ['Invalid attribute name: ', invalid.to_s]]
192
214
  end
193
215
  # TODO: check values/types
@@ -218,11 +240,6 @@ module OData
218
240
  @child_method.call.count
219
241
  end
220
242
 
221
- # TODO: fix design
222
- def navigated_coll
223
- true
224
- end
225
-
226
243
  def dataset
227
244
  @child_dataset_method.call
228
245
  end
@@ -236,6 +253,14 @@ module OData
236
253
  y.each { |enty| yield enty }
237
254
  end
238
255
 
256
+ def type_name
257
+ superclass.type_name
258
+ end
259
+
260
+ def media_handler
261
+ superclass.media_handler
262
+ end
263
+
239
264
  def to_a
240
265
  y = @child_method.call
241
266
  y.to_a
@@ -254,6 +279,8 @@ module OData
254
279
  # childattrib(collection)
255
280
  @child_method = parent.method(childattrib.to_sym)
256
281
  @child_dataset_method = parent.method("#{childattrib}_dataset".to_sym)
282
+ @nav_parent = parent
283
+ @navattr_reflection = parent.class.association_reflections[childattrib.to_sym]
257
284
  prepare_pk
258
285
  prepare_fields
259
286
  # Now in this anonymous Class we can refine the "all, count and []
@@ -269,11 +296,98 @@ module OData
269
296
  # 'Race[12].RaceType'
270
297
  # where Race[12] would be our self and 'RaceType' is the single
271
298
  # childattrib entity
272
- child_method = method(childattrib.to_sym)
273
- child_method.call
299
+
300
+ # when the child attribute is nil (because the FK is NULL on DB)
301
+ # then we return a Nil... wrapper object. This object then
302
+ # allows to receive a POST operation that would actually create the nav attribute entity
303
+
304
+ ret = method(childattrib.to_sym).call ||
305
+ OData::NilNavigationAttribute.new(self, childattrib)
306
+
307
+ ret
274
308
  end
275
309
  end
276
310
  # end of module ODataEntity
311
+ module Entity
312
+ include EntityBase
313
+ end
314
+
315
+ module NonMediaEntity
316
+ # non media entity metadata for json h
317
+ def metadata_h(uribase:)
318
+ { uri: uri(uribase),
319
+ type: type_name }
320
+ end
321
+
322
+ def values_for_odata
323
+ values.dup
324
+ end
325
+
326
+ # in case of a non media entity, we have to return an error on $value request
327
+ def odata_media_value_get(req)
328
+ return BadRequestNonMediaValue.odata_get
329
+ end
330
+
331
+ # in case of a non media entity, we have to return an error on $value PUT
332
+ def odata_media_value_put(req)
333
+ return BadRequestNonMediaValue.odata_get
334
+ end
335
+ end
336
+
337
+ module MediaEntity
338
+ # media entity metadata for json h
339
+ def metadata_h(uribase:)
340
+ { uri: uri(uribase),
341
+ type: type_name,
342
+ media_src: media_src(uribase),
343
+ edit_media: media_src(uribase),
344
+ content_type: @values[:content_type] }
345
+ end
346
+
347
+ def media_src(urbase)
348
+ "#{uri(urbase)}/$value"
349
+ end
350
+
351
+ # directory where to put/find the media files for this entity-type
352
+ def klass_dir
353
+ type_name
354
+ end
355
+
356
+ # # this is just ModelKlass/pk as a single string
357
+ # def qualified_media_path_id
358
+ # "#{self.class}/#{media_path_id}"
359
+ # end
360
+
361
+ def values_for_odata
362
+ ret = values.dup
363
+ ret.delete(:content_type)
364
+ ret
365
+ end
366
+
367
+ # real implementation for returning $value for a media entity
368
+ def odata_media_value_get(req)
369
+ # delegate to the media handler on collection(ie class) level
370
+ self.class.media_handler.odata_get(request: req, entity: self)
371
+ end
372
+
373
+ # real implementation for replacing $value for a media entity
374
+ def odata_media_value_put(req)
375
+ model = self.class
376
+ req.with_media_data do |data, mimetype, filename|
377
+ emdata = { :content_type => mimetype }
378
+ if req.in_changeset
379
+ set_fields(emdata, model.data_fields, missing: :skip)
380
+ save(transaction: false)
381
+ else
382
+ update_fields(emdata, model.data_fields, missing: :skip)
383
+ end
384
+ model.media_handler.replace_file(data: data,
385
+ entity: self,
386
+ filename: filename)
387
+ [204, {}, []]
388
+ end
389
+ end
390
+ end
277
391
 
278
392
  # for a single public key
279
393
  module EntitySinglePK
@@ -281,15 +395,22 @@ module OData
281
395
  def pk_uri
282
396
  pk
283
397
  end
398
+
399
+ def media_path_id
400
+ pk.to_s
401
+ end
284
402
  end
285
403
 
286
404
  # for multiple key
287
405
  module EntityMultiPK
288
406
  include Entity
289
407
  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(',')
408
+ # pk_hash is provided by Sequel
409
+ self.pk_hash.map { |k, v| "#{k}='#{v}'" }.join(',')
410
+ end
411
+
412
+ def media_path_id
413
+ self.pk_hash.values.join('_')
293
414
  end
294
415
  end
295
416
  end