safrano 0.3.2 → 0.3.3

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,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