safrano 0.4.1 → 0.4.6

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 +155 -99
  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 +183 -216
  23. data/lib/odata/error.rb +195 -31
  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 +639 -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 +20 -10
  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 +274 -219
  52. data/lib/safrano/version.rb +3 -1
  53. data/lib/sequel/plugins/join_by_paths.rb +17 -29
  54. metadata +34 -11
@@ -1,61 +1,68 @@
1
- require 'odata/error.rb'
1
+ # frozen_string_literal: true
2
2
 
3
- require_relative 'filter/parse.rb'
4
- require_relative 'filter/sequel.rb'
3
+ require 'odata/error'
5
4
 
6
- # a few helper method
7
- class String
8
- MASK_RGX = /'((?:[^']|(?:\'{2}))*)'/.freeze
9
- UNMASK_RGX = /'(%?)(\$\d+)(%?)'/.freeze
10
- def with_mask_quoted_substrings!
11
- cnt = 0
12
- repl = {}
13
- gsub!(MASK_RGX) do |_m|
14
- cnt += 1
15
- repl["$#{cnt}"] = Regexp.last_match(1)
16
- "'$#{cnt}'"
5
+ require_relative 'filter/parse'
6
+ require_relative 'filter/sequel'
7
+
8
+ # filter base class and subclass in our OData namespace
9
+ module Safrano
10
+ class FilterBase
11
+ # re-useable empty filtering (idempotent)
12
+ EmptyFilter = new.freeze
13
+
14
+ def self.factory(filterstr)
15
+ filterstr.nil? ? EmptyFilter : FilterByParse.new(filterstr)
16
+ end
17
+
18
+ def apply_to_dataset(dtcx)
19
+ Contract.valid(dtcx)
17
20
  end
18
- yield self
19
21
 
20
- gsub!(UNMASK_RGX) do |_m|
21
- k = Regexp.last_match(2).to_s
22
- "'#{Regexp.last_match(1)}#{repl[k]}#{Regexp.last_match(3)}'"
22
+ # finalize
23
+ def finalize(_jh) Contract::OK end
24
+
25
+ def empty?
26
+ true
23
27
  end
24
- end
25
28
 
26
- def with_mask_quoted_substrings
27
- cnt = 0
28
- repl = {}
29
- tmpstr = gsub(MASK_RGX) do |_m|
30
- cnt += 1
31
- repl["$#{cnt}"] = Regexp.last_match(1)
32
- "'$#{cnt}'"
29
+ def parse_error?
30
+ false
33
31
  end
34
- yield tmpstr
35
32
  end
36
- end
37
33
 
38
- # filter base class and subclass in our OData namespace
39
- module OData
40
34
  # should handle everything by parsing
41
- class FilterByParse
42
- def initialize(filterstr, jh)
35
+ class FilterByParse < FilterBase
36
+ attr_reader :filterstr
37
+
38
+ def initialize(filterstr)
43
39
  @filterstr = filterstr.dup
44
- @ast = OData::Filter::Parser.new(@filterstr).build
45
- @jh = jh
40
+ @ast = Safrano::Filter::Parser.new(@filterstr).build
46
41
  end
47
42
 
48
- def apply_to_dataset(dtcx)
49
- filtexpr = @ast.sequel_expr(@jh)
50
- dtcx = @jh.dataset(dtcx).where(filtexpr).select_all(@jh.start_model.table_name)
43
+ # this build's up the Sequel Filter Expression, and as a side effect,
44
+ # it also finalizes the join helper that we need for the start dataset join
45
+ # the join-helper is shared by the order-by object and was potentially already
46
+ # partly built on order-by object creation.
47
+ def finalize(jh)
48
+ @filtexpr = @ast.if_valid { |ast| ast.sequel_expr(jh) }
51
49
  end
52
50
 
53
- def sequel_expr
54
- @ast.sequel_expr(@jh)
51
+ def apply_to_dataset(dtcx)
52
+ # normally finalize is called before, and thus @filtexpr is set
53
+ @filtexpr.map_result! { |f| dtcx.where(f) }
55
54
  end
56
55
 
56
+ # Note: this is really only *parse* error, ie the error encounterd while
57
+ # trying to build the AST
58
+ # Later when evaluating the AST, there can be other errors, they shall
59
+ # be tracked with @error
57
60
  def parse_error?
58
- @ast.kind_of? StandardError
61
+ @ast.error
62
+ end
63
+
64
+ def empty?
65
+ false
59
66
  end
60
67
  end
61
68
  end
@@ -1,29 +1,44 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack'
2
4
  require 'fileutils'
3
- require_relative './navigation_attribute.rb'
5
+ require_relative './navigation_attribute'
4
6
 
5
- module OData
7
+ module Safrano
6
8
  module Media
7
9
  # base class for Media Handler
8
10
  class Handler
11
+ def check_before_create(data:,
12
+ entity:,
13
+ filename:)
14
+ Contract::OK
15
+ end
9
16
  end
10
17
 
11
18
  # Simple static File/Directory based media store handler
12
19
  # similar to Rack::Static
13
20
  # with a flat directory structure
14
21
  class Static < Handler
15
-
16
- def initialize(root: nil)
22
+ def initialize(root: nil, mediaklass:)
17
23
  @root = File.absolute_path(root || Dir.pwd)
18
24
  @file_server = ::Rack::File.new(@root)
25
+ @media_class = mediaklass
26
+ @media_dir_name = mediaklass.to_s
27
+ register
28
+ end
29
+
30
+ def register
31
+ @abs_klass_dir = File.absolute_path(@media_dir_name, @root)
32
+ end
33
+
34
+ def create_abs_class_dir
35
+ FileUtils.makedirs @abs_klass_dir unless Dir.exist?(@abs_klass_dir)
19
36
  end
20
37
 
21
- # TODO testcase and better abs_klass_dir design
22
- def register(klass)
23
- abs_klass_dir = File.absolute_path(klass.type_name, @root)
24
- FileUtils.makedirs abs_klass_dir unless Dir.exists?(abs_klass_dir)
38
+ def finalize
39
+ create_abs_class_dir
25
40
  end
26
-
41
+
27
42
  # minimal working implementation...
28
43
  # Note: @file_server works relative to @root directory
29
44
  def odata_get(request:, entity:)
@@ -37,35 +52,33 @@ module OData
37
52
  fsret
38
53
  end
39
54
 
40
- # TODO perf: this can be precalculated and cached on MediaModelKlass level
41
- # and passed as argument to save_file
42
- # eg. /@root/Photo
43
- def abs_klass_dir(entity)
44
- File.absolute_path(entity.klass_dir, @root)
45
- end
46
-
47
55
  # this is relative to @root
48
56
  # eg. Photo/1
49
57
  def media_path(entity)
50
- File.join(entity.klass_dir, media_directory(entity))
58
+ File.join(@media_dir_name, media_directory(entity))
51
59
  end
52
-
60
+
53
61
  # relative to @root
54
- # eg Photo/1/pommes-topaz.jpg
62
+ # eg Photo/1/1
55
63
  def filename(entity)
56
64
  Dir.chdir(abs_path(entity)) do
57
65
  # simple design: one file per directory, and the directory
58
66
  # contains the media entity-id --> implicit link between the media
59
67
  # entity
60
- File.join(media_path(entity), Dir.glob('*').first)
68
+ File.join(media_path(entity), Dir.glob('*').max)
61
69
  end
62
70
  end
63
-
71
+
64
72
  # /@root/Photo/1
65
73
  def abs_path(entity)
66
74
  File.absolute_path(media_path(entity), @root)
67
75
  end
68
76
 
77
+ # absolute filename
78
+ def abs_filename(entity)
79
+ File.absolute_path(filename(entity), @root)
80
+ end
81
+
69
82
  # this is relative to abs_klass_dir(entity) eg to /@root/Photo
70
83
  # simplest implementation is media_directory = entity.media_path_id
71
84
  # --> we get a 1 level depth flat directory structure
@@ -75,23 +88,23 @@ module OData
75
88
 
76
89
  def in_media_directory(entity)
77
90
  mpi = media_directory(entity)
78
- Dir.mkdir mpi unless Dir.exists?(mpi)
91
+ Dir.mkdir mpi unless Dir.exist?(mpi)
79
92
  Dir.chdir mpi do
80
93
  yield
81
94
  end
82
95
  end
83
96
 
84
- def odata_delete(request:, entity:)
85
- Dir.chdir(abs_klass_dir(entity)) do
97
+ def odata_delete(entity:)
98
+ Dir.chdir(@abs_klass_dir) do
86
99
  in_media_directory(entity) do
87
100
  Dir.glob('*').each { |oldf| File.delete(oldf) }
88
101
  end
89
- end
102
+ end
90
103
  end
91
104
 
92
105
  # Here as well, MVP implementation
93
106
  def save_file(data:, filename:, entity:)
94
- Dir.chdir(abs_klass_dir(entity)) do
107
+ Dir.chdir(@abs_klass_dir) do
95
108
  in_media_directory(entity) do
96
109
  filename = '1'
97
110
  File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
@@ -103,19 +116,22 @@ module OData
103
116
  # after each upload, so that clients get informed about new versions
104
117
  # of the same media ressource
105
118
  def ressource_version(entity)
106
- Dir.chdir(abs_klass_dir(entity)) do
119
+ Dir.chdir(@abs_klass_dir) do
107
120
  in_media_directory(entity) do
108
- Dir.glob('*').last
121
+ Dir.glob('*').max
109
122
  end
110
- end
123
+ end
111
124
  end
112
-
125
+
113
126
  # Here as well, MVP implementation
114
127
  def replace_file(data:, filename:, entity:)
115
- Dir.chdir(abs_klass_dir(entity)) do
128
+ Dir.chdir(@abs_klass_dir) do
116
129
  in_media_directory(entity) do
117
130
  version = nil
118
- Dir.glob('*').each { |oldf| version = oldf; File.delete(oldf) }
131
+ Dir.glob('*').sort.each do |oldf|
132
+ version = oldf
133
+ File.delete(oldf)
134
+ end
119
135
  filename = (version.to_i + 1).to_s
120
136
  File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
121
137
  end
@@ -125,107 +141,105 @@ module OData
125
141
  # Simple static File/Directory based media store handler
126
142
  # similar to Rack::Static
127
143
  # with directory Tree structure
128
-
144
+
129
145
  class StaticTree < Static
130
-
131
- SEP = '/00/'
132
-
133
- def StaticTree.path_builder(ids)
134
- ids.map{|id| id.to_s.chars.join('/')}.join(SEP) + '/v'
146
+ SEP = '/00/'.freeze
147
+ VERS = '/v'.freeze
148
+
149
+ def self.path_builder(ids)
150
+ ids.map { |id| id.to_s.chars.join('/') }.join(SEP) << VERS
135
151
  end
136
-
152
+
137
153
  # this is relative to abs_klass_dir(entity) eg to /@root/Photo
138
154
  # tree-structure
139
- # media_path_ids = 1 --> 1
140
- # media_path_ids = 15 --> 1/5
141
- # media_path_ids = 555 --> 5/5/5
142
- # media_path_ids = 5,5,5 --> 5/00/5/00/5
143
- # media_path_ids = 5,00,5 --> 5/00/0/0/00/5
144
- # media_path_ids = 5,xyz,5 --> 5/00/x/y/z/00/5
155
+ # media_path_ids = 1 --> 1/v
156
+ # media_path_ids = 15 --> 1/5/v
157
+ # media_path_ids = 555 --> 5/5/5/v
158
+ # media_path_ids = 5,5,5 --> 5/00/5/00/5/v
159
+ # media_path_ids = 5,00,5 --> 5/00/0/0/00/5/v
160
+ # media_path_ids = 5,xyz,5 --> 5/00/x/y/z/00/5/v
145
161
  def media_directory(entity)
146
162
  StaticTree.path_builder(entity.media_path_ids)
147
- # entity.media_path_ids.map{|id| id.to_s.chars.join('/')}.join(@sep)
148
163
  end
149
164
 
150
165
  def in_media_directory(entity)
151
166
  mpi = media_directory(entity)
152
- FileUtils.makedirs mpi unless Dir.exists?(mpi)
153
- Dir.chdir mpi do
154
- yield
155
- end
167
+ FileUtils.makedirs mpi unless Dir.exist?(mpi)
168
+ Dir.chdir(mpi) { yield }
156
169
  end
157
170
 
158
- def odata_delete(request:, entity:)
159
- Dir.chdir(abs_klass_dir(entity)) do
171
+ def odata_delete(entity:)
172
+ Dir.chdir(@abs_klass_dir) do
160
173
  in_media_directory(entity) do
161
- Dir.glob('*').each { |oldf| File.delete(oldf) if File.file?(oldf) }
174
+ Dir.glob('*').sort.each { |oldf| File.delete(oldf) if File.file?(oldf) }
162
175
  end
163
- end
176
+ end
164
177
  end
165
-
178
+
179
+ # Here as well, MVP implementation
180
+ # def replace_file(data:, filename:, entity:)
181
+ # Dir.chdir(abs_klass_dir(entity)) do
182
+ # in_media_directory(entity) do
183
+ # Dir.glob('*').each { |oldf| File.delete(oldf) if File.file?(oldf) }
184
+ # File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
185
+ # end
186
+ # end
187
+ # end
166
188
  # Here as well, MVP implementation
167
- # def replace_file(data:, filename:, entity:)
168
- # Dir.chdir(abs_klass_dir(entity)) do
169
- # in_media_directory(entity) do
170
- # Dir.glob('*').each { |oldf| File.delete(oldf) if File.file?(oldf) }
171
- # File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
172
- # end
173
- # end
174
- # end
175
- # Here as well, MVP implementation
176
189
  def replace_file(data:, filename:, entity:)
177
- Dir.chdir(abs_klass_dir(entity)) do
190
+ Dir.chdir(@abs_klass_dir) do
178
191
  in_media_directory(entity) do
179
192
  version = nil
180
- Dir.glob('*').each { |oldf| version = oldf; File.delete(oldf) }
193
+ Dir.glob('*').sort.each do |oldf|
194
+ version = oldf
195
+ File.delete(oldf)
196
+ end
181
197
  filename = (version.to_i + 1).to_s
182
198
  File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
183
199
  end
184
200
  end
185
201
  end
186
-
187
202
  end
188
-
189
203
  end
190
204
 
191
205
  # special handling for media entity
192
206
  module EntityClassMedia
193
207
  attr_reader :media_handler
194
208
  attr_reader :slug_field
195
-
209
+
196
210
  # API method for defining the media handler
197
211
  # eg.
198
212
  # publish_media_model photos do
199
- # use OData::Media::Static, :root => '/media_root'
213
+ # use Safrano::Media::Static, :root => '/media_root'
200
214
  # end
201
215
 
202
216
  def set_default_media_handler
203
- @media_handler = OData::Media::Static.new
217
+ @media_handler = Safrano::Media::Static.new(mediaklass: self)
218
+ end
219
+
220
+ def finalize_media
221
+ @media_handler.finalize
204
222
  end
205
223
 
206
224
  def use(klass, args)
225
+ args[:mediaklass] = self
207
226
  @media_handler = klass.new(**args)
227
+ @media_handler.create_abs_class_dir
208
228
  end
209
229
 
210
230
  # API method for setting the model field mapped to SLUG on upload
211
231
  def slug(inp)
212
- @slug_field = inp
232
+ @slug_field = inp
213
233
  end
214
-
234
+
215
235
  def api_check_media_fields
216
- unless self.db_schema.has_key?(:content_type)
217
- raise OData::API::MediaModelError, self
218
- end
219
- # unless self.db_schema.has_key?(:media_src)
220
- # raise OData::API::MediaModelError, self
221
- # end
236
+ raise(Safrano::API::MediaModelError, self) unless db_schema.key?(:content_type)
222
237
  end
223
238
 
224
- # END API methods
239
+ # END API methods
225
240
 
226
241
  def new_media_entity(mimetype:)
227
- nh = {}
228
- nh['content_type'] = mimetype
242
+ nh = { 'content_type' => mimetype }
229
243
  new_from_hson_h(nh)
230
244
  end
231
245
 
@@ -238,11 +252,11 @@ module OData
238
252
  # NOTE: we will implement this first in a MVP way. There will be plenty of
239
253
  # potential future enhancements (performance, scallability, flexibility... with added complexity)
240
254
  # 4. create relation to parent if needed
241
- def odata_create_entity_and_relation(req, assoc, parent)
255
+ def odata_create_entity_and_relation(req, assoc = nil, parent = nil)
242
256
  req.with_media_data do |data, mimetype, filename|
243
257
  ## future enhancement: validate allowed mimetypes ?
244
258
  # if (invalid = invalid_media_mimetype(mimetype))
245
- # ::OData::Request::ON_CGST_ERROR.call(req)
259
+ # ::Safrano::Request::ON_CGST_ERROR.call(req)
246
260
  # return [422, {}, ['Invalid mime type: ', invalid.to_s]]
247
261
  # end
248
262
 
@@ -252,25 +266,44 @@ module OData
252
266
 
253
267
  if slug_field
254
268
 
255
- new_entity.set_fields({ slug_field => filename},
256
- data_fields,
257
- missing: :skip)
258
- end
259
-
260
- # to_one rels are create with FK data set on the parent entity
261
- if parent
262
- odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
263
- else
264
- # in-changeset requests get their own transaction
265
- new_entity.save(transaction: !req.in_changeset)
269
+ new_entity.set_fields({ slug_field => filename },
270
+ data_fields,
271
+ missing: :skip)
266
272
  end
267
273
 
268
- req.register_content_id_ref(new_entity)
269
- new_entity.copy_request_infos(req)
274
+ # call before_create_entity media hook
275
+ new_entity.before_create_media_entity(data: data, mimetype: mimetype) if new_entity.respond_to? :before_create_media_entity
276
+
277
+ media_handler.check_before_create(data: data,
278
+ entity: new_entity,
279
+ filename: filename).if_valid { |_ret|
280
+ # to_one rels are create with FK data set on the parent entity
281
+ if parent
282
+ odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
283
+ else
284
+ # in-changeset requests get their own transaction
285
+ new_entity.save(transaction: !req.in_changeset)
286
+ end
287
+
288
+ req.register_content_id_ref(new_entity)
289
+ new_entity.copy_request_infos(req)
290
+
291
+ # call before_create_media hook
292
+ new_entity.before_create_media if new_entity.respond_to? :before_create_media
293
+
294
+ media_handler.save_file(data: data,
295
+ entity: new_entity,
296
+ filename: filename)
270
297
 
271
- media_handler.save_file(data: data, entity: new_entity, filename: filename)
298
+ # call after_create_media hook
299
+ new_entity.after_create_media if new_entity.respond_to? :after_create_media
300
+
301
+ # json is default content type so we dont need to specify it here again
302
+ # Contract.valid([201, EMPTY_HASH, new_entity.to_odata_post_json(service: req.service)])
303
+ # TODO quirks array mode !
304
+ Contract.valid([201, EMPTY_HASH, new_entity.to_odata_create_json(request: req)])
305
+ }.tap_error { |e| return e.odata_get(req) }.result
272
306
 
273
- [201, CT_JSON, new_entity.to_odata_post_json(service: req.service)]
274
307
  else # TODO: other formats
275
308
  415
276
309
  end
@@ -278,3 +311,26 @@ module OData
278
311
  end
279
312
  end
280
313
  end
314
+
315
+ # deprecated
316
+ # REMOVE 0.6
317
+ module OData
318
+ module Media
319
+ class Static < ::Safrano::Media::Static
320
+ def initialize(root: nil, mediaklass:)
321
+ ::Safrano::Deprecation.deprecate('OData::Media::Static',
322
+ 'Use Safrano::Media::Static instead')
323
+
324
+ super
325
+ end
326
+ end
327
+ class StaticTree < ::Safrano::Media::StaticTree
328
+ def initialize(root: nil, mediaklass:)
329
+ ::Safrano::Deprecation.deprecate('OData::Media::StaticTree',
330
+ 'Use Safrano::Media::StaticTree instead')
331
+
332
+ super
333
+ end
334
+ end
335
+ end
336
+ end