safrano 0.4.0 → 0.4.5

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 +145 -74
  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 +151 -197
  23. data/lib/odata/error.rb +175 -32
  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 +637 -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 +19 -11
  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 +264 -220
  52. data/lib/safrano/version.rb +3 -1
  53. data/lib/sequel/plugins/join_by_paths.rb +17 -29
  54. metadata +34 -12
@@ -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,8 +1,10 @@
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
@@ -12,39 +14,45 @@ module OData
12
14
  # similar to Rack::Static
13
15
  # with a flat directory structure
14
16
  class Static < Handler
15
-
16
- def initialize(root: nil)
17
+ def initialize(root: nil, mediaklass:)
17
18
  @root = File.absolute_path(root || Dir.pwd)
18
19
  @file_server = ::Rack::File.new(@root)
20
+ @media_class = mediaklass
21
+ @media_dir_name = mediaklass.to_s
22
+ register
23
+ end
24
+
25
+ def register
26
+ @abs_klass_dir = File.absolute_path(@media_dir_name, @root)
19
27
  end
20
28
 
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)
29
+ def create_abs_class_dir
30
+ FileUtils.makedirs @abs_klass_dir unless Dir.exist?(@abs_klass_dir)
25
31
  end
26
-
32
+
33
+ def finalize
34
+ create_abs_class_dir
35
+ end
36
+
27
37
  # minimal working implementation...
28
38
  # Note: @file_server works relative to @root directory
29
39
  def odata_get(request:, entity:)
30
40
  media_env = request.env.dup
31
41
  media_env['PATH_INFO'] = filename(entity)
32
- @file_server.call(media_env)
33
- end
34
-
35
- # TODO perf: this can be precalculated and cached on MediaModelKlass level
36
- # and passed as argument to save_file
37
- # eg. /@root/Photo
38
- def abs_klass_dir(entity)
39
- File.absolute_path(entity.klass_dir, @root)
42
+ fsret = @file_server.call(media_env)
43
+ if fsret.first == 200
44
+ # provide own content type as we keep it in the media entity
45
+ fsret[1]['Content-Type'] = entity.content_type
46
+ end
47
+ fsret
40
48
  end
41
49
 
42
50
  # this is relative to @root
43
51
  # eg. Photo/1
44
52
  def media_path(entity)
45
- File.join(entity.klass_dir, media_directory(entity))
53
+ File.join(@media_dir_name, media_directory(entity))
46
54
  end
47
-
55
+
48
56
  # relative to @root
49
57
  # eg Photo/1/pommes-topaz.jpg
50
58
  def filename(entity)
@@ -52,15 +60,20 @@ module OData
52
60
  # simple design: one file per directory, and the directory
53
61
  # contains the media entity-id --> implicit link between the media
54
62
  # entity
55
- File.join(media_path(entity), Dir.glob('*').first)
63
+ File.join(media_path(entity), Dir.glob('*').min)
56
64
  end
57
65
  end
58
-
66
+
59
67
  # /@root/Photo/1
60
68
  def abs_path(entity)
61
69
  File.absolute_path(media_path(entity), @root)
62
70
  end
63
71
 
72
+ # absolute filename
73
+ def abs_filename(entity)
74
+ File.absolute_path(filename(entity), @root)
75
+ end
76
+
64
77
  # this is relative to abs_klass_dir(entity) eg to /@root/Photo
65
78
  # simplest implementation is media_directory = entity.media_path_id
66
79
  # --> we get a 1 level depth flat directory structure
@@ -70,34 +83,51 @@ module OData
70
83
 
71
84
  def in_media_directory(entity)
72
85
  mpi = media_directory(entity)
73
- Dir.mkdir mpi unless Dir.exists?(mpi)
86
+ Dir.mkdir mpi unless Dir.exist?(mpi)
74
87
  Dir.chdir mpi do
75
88
  yield
76
89
  end
77
90
  end
78
91
 
79
- def odata_delete(request:, entity:)
80
- Dir.chdir(abs_klass_dir(entity)) do
92
+ def odata_delete(entity:)
93
+ Dir.chdir(@abs_klass_dir) do
81
94
  in_media_directory(entity) do
82
95
  Dir.glob('*').each { |oldf| File.delete(oldf) }
83
96
  end
84
- end
97
+ end
85
98
  end
86
99
 
87
100
  # Here as well, MVP implementation
88
101
  def save_file(data:, filename:, entity:)
89
- Dir.chdir(abs_klass_dir(entity)) do
102
+ Dir.chdir(@abs_klass_dir) do
90
103
  in_media_directory(entity) do
104
+ filename = '1'
91
105
  File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
92
106
  end
93
107
  end
94
108
  end
95
109
 
110
+ # needed for having a changing media ressource "source" metadata
111
+ # after each upload, so that clients get informed about new versions
112
+ # of the same media ressource
113
+ def ressource_version(entity)
114
+ Dir.chdir(@abs_klass_dir) do
115
+ in_media_directory(entity) do
116
+ Dir.glob('*').max
117
+ end
118
+ end
119
+ end
120
+
96
121
  # Here as well, MVP implementation
97
122
  def replace_file(data:, filename:, entity:)
98
- Dir.chdir(abs_klass_dir(entity)) do
123
+ Dir.chdir(@abs_klass_dir) do
99
124
  in_media_directory(entity) do
100
- Dir.glob('*').each { |oldf| File.delete(oldf) }
125
+ version = nil
126
+ Dir.glob('*').sort.each do |oldf|
127
+ version = oldf
128
+ File.delete(oldf)
129
+ end
130
+ filename = (version.to_i + 1).to_s
101
131
  File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
102
132
  end
103
133
  end
@@ -106,15 +136,15 @@ module OData
106
136
  # Simple static File/Directory based media store handler
107
137
  # similar to Rack::Static
108
138
  # with directory Tree structure
109
-
139
+
110
140
  class StaticTree < Static
111
-
112
- SEP = '/00/'
113
-
114
- def StaticTree.path_builder(ids)
115
- ids.map{|id| id.to_s.chars.join('/')}.join(SEP)
141
+ SEP = '/00/'.freeze
142
+ VERS = '/v'.freeze
143
+
144
+ def self.path_builder(ids)
145
+ ids.map { |id| id.to_s.chars.join('/') }.join(SEP) << VERS
116
146
  end
117
-
147
+
118
148
  # this is relative to abs_klass_dir(entity) eg to /@root/Photo
119
149
  # tree-structure
120
150
  # media_path_ids = 1 --> 1
@@ -125,78 +155,87 @@ module OData
125
155
  # media_path_ids = 5,xyz,5 --> 5/00/x/y/z/00/5
126
156
  def media_directory(entity)
127
157
  StaticTree.path_builder(entity.media_path_ids)
128
- # entity.media_path_ids.map{|id| id.to_s.chars.join('/')}.join(@sep)
158
+ # entity.media_path_ids.map{|id| id.to_s.chars.join('/')}.join(@sep)
129
159
  end
130
160
 
131
161
  def in_media_directory(entity)
132
162
  mpi = media_directory(entity)
133
- FileUtils.makedirs mpi unless Dir.exists?(mpi)
134
- Dir.chdir mpi do
135
- yield
136
- end
163
+ FileUtils.makedirs mpi unless Dir.exist?(mpi)
164
+ Dir.chdir(mpi) { yield }
137
165
  end
138
166
 
139
- def odata_delete(request:, entity:)
140
- Dir.chdir(abs_klass_dir(entity)) do
167
+ def odata_delete(entity:)
168
+ Dir.chdir(@abs_klass_dir) do
141
169
  in_media_directory(entity) do
142
- Dir.glob('*').each { |oldf| File.delete(oldf) if File.file?(oldf) }
170
+ Dir.glob('*').sort.each { |oldf| File.delete(oldf) if File.file?(oldf) }
143
171
  end
144
- end
172
+ end
145
173
  end
146
-
174
+
175
+ # Here as well, MVP implementation
176
+ # def replace_file(data:, filename:, entity:)
177
+ # Dir.chdir(abs_klass_dir(entity)) do
178
+ # in_media_directory(entity) do
179
+ # Dir.glob('*').each { |oldf| File.delete(oldf) if File.file?(oldf) }
180
+ # File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
181
+ # end
182
+ # end
183
+ # end
147
184
  # Here as well, MVP implementation
148
185
  def replace_file(data:, filename:, entity:)
149
- Dir.chdir(abs_klass_dir(entity)) do
186
+ Dir.chdir(@abs_klass_dir) do
150
187
  in_media_directory(entity) do
151
- Dir.glob('*').each { |oldf| File.delete(oldf) if File.file?(oldf) }
188
+ version = nil
189
+ Dir.glob('*').sort.each do |oldf|
190
+ version = oldf
191
+ File.delete(oldf)
192
+ end
193
+ filename = (version.to_i + 1).to_s
152
194
  File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
153
195
  end
154
196
  end
155
197
  end
156
-
157
-
158
198
  end
159
-
160
199
  end
161
200
 
162
201
  # special handling for media entity
163
202
  module EntityClassMedia
164
203
  attr_reader :media_handler
165
204
  attr_reader :slug_field
166
-
205
+
167
206
  # API method for defining the media handler
168
207
  # eg.
169
208
  # publish_media_model photos do
170
- # use OData::Media::Static, :root => '/media_root'
209
+ # use Safrano::Media::Static, :root => '/media_root'
171
210
  # end
172
211
 
173
212
  def set_default_media_handler
174
- @media_handler = OData::Media::Static.new
213
+ @media_handler = Safrano::Media::Static.new(mediaklass: self)
175
214
  end
176
215
 
177
- def use(klass, *args)
178
- @media_handler = klass.new(*args)
216
+ def finalize_media
217
+ @media_handler.finalize
218
+ end
219
+
220
+ def use(klass, args)
221
+ args[:mediaklass] = self
222
+ @media_handler = klass.new(**args)
223
+ @media_handler.create_abs_class_dir
179
224
  end
180
225
 
181
226
  # API method for setting the model field mapped to SLUG on upload
182
227
  def slug(inp)
183
- @slug_field = inp
228
+ @slug_field = inp
184
229
  end
185
-
230
+
186
231
  def api_check_media_fields
187
- unless self.db_schema.has_key?(:content_type)
188
- raise OData::API::MediaModelError, self
189
- end
190
- # unless self.db_schema.has_key?(:media_src)
191
- # raise OData::API::MediaModelError, self
192
- # end
232
+ raise(Safrano::API::MediaModelError, self) unless db_schema.key?(:content_type)
193
233
  end
194
234
 
195
- # END API methods
235
+ # END API methods
196
236
 
197
237
  def new_media_entity(mimetype:)
198
- nh = {}
199
- nh['content_type'] = mimetype
238
+ nh = { 'content_type' => mimetype }
200
239
  new_from_hson_h(nh)
201
240
  end
202
241
 
@@ -209,11 +248,11 @@ module OData
209
248
  # NOTE: we will implement this first in a MVP way. There will be plenty of
210
249
  # potential future enhancements (performance, scallability, flexibility... with added complexity)
211
250
  # 4. create relation to parent if needed
212
- def odata_create_entity_and_relation(req, assoc, parent)
251
+ def odata_create_entity_and_relation(req, assoc = nil, parent = nil)
213
252
  req.with_media_data do |data, mimetype, filename|
214
253
  ## future enhancement: validate allowed mimetypes ?
215
254
  # if (invalid = invalid_media_mimetype(mimetype))
216
- # ::OData::Request::ON_CGST_ERROR.call(req)
255
+ # ::Safrano::Request::ON_CGST_ERROR.call(req)
217
256
  # return [422, {}, ['Invalid mime type: ', invalid.to_s]]
218
257
  # end
219
258
 
@@ -223,11 +262,11 @@ module OData
223
262
 
224
263
  if slug_field
225
264
 
226
- new_entity.set_fields({ slug_field => filename},
227
- data_fields,
228
- missing: :skip)
265
+ new_entity.set_fields({ slug_field => filename },
266
+ data_fields,
267
+ missing: :skip)
229
268
  end
230
-
269
+
231
270
  # to_one rels are create with FK data set on the parent entity
232
271
  if parent
233
272
  odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
@@ -239,9 +278,18 @@ module OData
239
278
  req.register_content_id_ref(new_entity)
240
279
  new_entity.copy_request_infos(req)
241
280
 
242
- media_handler.save_file(data: data, entity: new_entity, filename: filename)
281
+ # call before_create_media hook
282
+ new_entity.before_create_media if new_entity.respond_to? :before_create_media
283
+
284
+ media_handler.save_file(data: data,
285
+ entity: new_entity,
286
+ filename: filename)
243
287
 
244
- [201, CT_JSON, new_entity.to_odata_post_json(service: req.service)]
288
+ # call after_create_media hook
289
+ new_entity.after_create_media if new_entity.respond_to? :after_create_media
290
+
291
+ # json is default content type so we dont need to specify it here again
292
+ [201, EMPTY_HASH, new_entity.to_odata_post_json(service: req.service)]
245
293
  else # TODO: other formats
246
294
  415
247
295
  end
@@ -249,3 +297,26 @@ module OData
249
297
  end
250
298
  end
251
299
  end
300
+
301
+ # deprecated
302
+ # REMOVE 0.6
303
+ module OData
304
+ module Media
305
+ class Static < ::Safrano::Media::Static
306
+ def initialize(root: nil, mediaklass:)
307
+ ::Safrano::Deprecation.deprecate('OData::Media::Static',
308
+ 'Use Safrano::Media::Static instead')
309
+
310
+ super
311
+ end
312
+ end
313
+ class StaticTree < ::Safrano::Media::StaticTree
314
+ def initialize(root: nil, mediaklass:)
315
+ ::Safrano::Deprecation.deprecate('OData::Media::StaticTree',
316
+ 'Use Safrano::Media::StaticTree instead')
317
+
318
+ super
319
+ end
320
+ end
321
+ end
322
+ end