safrano 0.3.2 → 0.4.2
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.
- checksums.yaml +4 -4
- data/lib/odata/attribute.rb +1 -1
- data/lib/odata/batch.rb +24 -10
- data/lib/odata/collection.rb +242 -96
- data/lib/odata/collection_filter.rb +40 -9
- data/lib/odata/collection_media.rb +279 -0
- data/lib/odata/collection_order.rb +46 -36
- data/lib/odata/common_logger.rb +59 -0
- data/lib/odata/entity.rb +268 -54
- data/lib/odata/error.rb +58 -17
- data/lib/odata/expand.rb +123 -0
- data/lib/odata/filter/error.rb +6 -0
- data/lib/odata/filter/parse.rb +4 -12
- data/lib/odata/filter/sequel.rb +11 -13
- data/lib/odata/filter/tree.rb +11 -15
- data/lib/odata/navigation_attribute.rb +150 -0
- data/lib/odata/relations.rb +2 -2
- data/lib/odata/select.rb +42 -0
- data/lib/odata/url_parameters.rb +51 -36
- data/lib/odata/walker.rb +12 -4
- data/lib/safrano.rb +23 -12
- data/lib/{safrano_core.rb → safrano/core.rb} +14 -3
- data/lib/{multipart.rb → safrano/multipart.rb} +51 -29
- data/lib/{odata_rack_builder.rb → safrano/odata_rack_builder.rb} +1 -1
- data/lib/{rack_app.rb → safrano/rack_app.rb} +15 -10
- data/lib/{request.rb → safrano/request.rb} +21 -8
- data/lib/{response.rb → safrano/response.rb} +1 -2
- data/lib/{sequel_join_by_paths.rb → safrano/sequel_join_by_paths.rb} +1 -1
- data/lib/{service.rb → safrano/service.rb} +93 -97
- data/lib/safrano/version.rb +3 -0
- data/lib/sequel/plugins/join_by_paths.rb +11 -10
- metadata +34 -15
@@ -37,25 +37,56 @@ end
|
|
37
37
|
|
38
38
|
# filter base class and subclass in our OData namespace
|
39
39
|
module OData
|
40
|
+
class FilterBase
|
41
|
+
# re-useable empty filtering (idempotent)
|
42
|
+
EmptyFilter = new
|
43
|
+
|
44
|
+
def self.factory(filterstr)
|
45
|
+
filterstr.nil? ? EmptyFilter : FilterByParse.new(filterstr)
|
46
|
+
end
|
47
|
+
|
48
|
+
def apply_to_dataset(dtcx)
|
49
|
+
dtcx
|
50
|
+
end
|
51
|
+
|
52
|
+
# finalize
|
53
|
+
def finalize(jh) end
|
54
|
+
|
55
|
+
def empty?
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
def parse_error?
|
60
|
+
false
|
61
|
+
end
|
62
|
+
end
|
40
63
|
# should handle everything by parsing
|
41
|
-
class FilterByParse
|
42
|
-
|
64
|
+
class FilterByParse < FilterBase
|
65
|
+
attr_reader :filterstr
|
66
|
+
def initialize(filterstr)
|
43
67
|
@filterstr = filterstr.dup
|
44
68
|
@ast = OData::Filter::Parser.new(@filterstr).build
|
45
|
-
@jh = jh
|
46
69
|
end
|
47
70
|
|
48
|
-
|
49
|
-
|
50
|
-
|
71
|
+
# this build's up the Sequel Filter Expression, and as a side effect,
|
72
|
+
# it also finalizes the join helper that we need for the start dataset join
|
73
|
+
# the join-helper is shared by the order-by object and was potentially already
|
74
|
+
# partly built on order-by object creation.
|
75
|
+
def finalize(jh)
|
76
|
+
@filtexpr = @ast.sequel_expr(jh)
|
51
77
|
end
|
52
78
|
|
53
|
-
def
|
54
|
-
@
|
79
|
+
def apply_to_dataset(dtcx)
|
80
|
+
# normally finalize is called before, and thus @filtexpr is set
|
81
|
+
dtcx.where(@filtexpr)
|
55
82
|
end
|
56
83
|
|
57
84
|
def parse_error?
|
58
|
-
@ast.
|
85
|
+
@ast.is_a? StandardError
|
86
|
+
end
|
87
|
+
|
88
|
+
def empty?
|
89
|
+
false
|
59
90
|
end
|
60
91
|
end
|
61
92
|
end
|
@@ -0,0 +1,279 @@
|
|
1
|
+
require 'rack'
|
2
|
+
require 'fileutils'
|
3
|
+
require_relative './navigation_attribute.rb'
|
4
|
+
|
5
|
+
module OData
|
6
|
+
module Media
|
7
|
+
# base class for Media Handler
|
8
|
+
class Handler
|
9
|
+
end
|
10
|
+
|
11
|
+
# Simple static File/Directory based media store handler
|
12
|
+
# similar to Rack::Static
|
13
|
+
# with a flat directory structure
|
14
|
+
class Static < Handler
|
15
|
+
def initialize(root: nil)
|
16
|
+
@root = File.absolute_path(root || Dir.pwd)
|
17
|
+
@file_server = ::Rack::File.new(@root)
|
18
|
+
end
|
19
|
+
|
20
|
+
# TODO: testcase and better abs_klass_dir design
|
21
|
+
def register(klass)
|
22
|
+
abs_klass_dir = File.absolute_path(klass.type_name, @root)
|
23
|
+
FileUtils.makedirs abs_klass_dir unless Dir.exist?(abs_klass_dir)
|
24
|
+
end
|
25
|
+
|
26
|
+
# minimal working implementation...
|
27
|
+
# Note: @file_server works relative to @root directory
|
28
|
+
def odata_get(request:, entity:)
|
29
|
+
media_env = request.env.dup
|
30
|
+
media_env['PATH_INFO'] = filename(entity)
|
31
|
+
fsret = @file_server.call(media_env)
|
32
|
+
if fsret.first == 200
|
33
|
+
# provide own content type as we keep it in the media entity
|
34
|
+
fsret[1]['Content-Type'] = entity.content_type
|
35
|
+
end
|
36
|
+
fsret
|
37
|
+
end
|
38
|
+
|
39
|
+
# TODO: [perf] this can be precalculated and cached on MediaModelKlass level
|
40
|
+
# and passed as argument to save_file
|
41
|
+
# eg. /@root/Photo
|
42
|
+
def abs_klass_dir(entity)
|
43
|
+
File.absolute_path(entity.klass_dir, @root)
|
44
|
+
end
|
45
|
+
|
46
|
+
# this is relative to @root
|
47
|
+
# eg. Photo/1
|
48
|
+
def media_path(entity)
|
49
|
+
File.join(entity.klass_dir, media_directory(entity))
|
50
|
+
end
|
51
|
+
|
52
|
+
# relative to @root
|
53
|
+
# eg Photo/1/pommes-topaz.jpg
|
54
|
+
def filename(entity)
|
55
|
+
Dir.chdir(abs_path(entity)) do
|
56
|
+
# simple design: one file per directory, and the directory
|
57
|
+
# contains the media entity-id --> implicit link between the media
|
58
|
+
# entity
|
59
|
+
File.join(media_path(entity), Dir.glob('*').first)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# /@root/Photo/1
|
64
|
+
def abs_path(entity)
|
65
|
+
File.absolute_path(media_path(entity), @root)
|
66
|
+
end
|
67
|
+
|
68
|
+
# this is relative to abs_klass_dir(entity) eg to /@root/Photo
|
69
|
+
# simplest implementation is media_directory = entity.media_path_id
|
70
|
+
# --> we get a 1 level depth flat directory structure
|
71
|
+
def media_directory(entity)
|
72
|
+
entity.media_path_id
|
73
|
+
end
|
74
|
+
|
75
|
+
def in_media_directory(entity)
|
76
|
+
mpi = media_directory(entity)
|
77
|
+
Dir.mkdir mpi unless Dir.exist?(mpi)
|
78
|
+
Dir.chdir mpi do
|
79
|
+
yield
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def odata_delete(entity:)
|
84
|
+
Dir.chdir(abs_klass_dir(entity)) do
|
85
|
+
in_media_directory(entity) do
|
86
|
+
Dir.glob('*').each { |oldf| File.delete(oldf) }
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Here as well, MVP implementation
|
92
|
+
def save_file(data:, filename:, entity:)
|
93
|
+
Dir.chdir(abs_klass_dir(entity)) do
|
94
|
+
in_media_directory(entity) do
|
95
|
+
filename = '1'
|
96
|
+
File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# needed for having a changing media ressource "source" metadata
|
102
|
+
# after each upload, so that clients get informed about new versions
|
103
|
+
# of the same media ressource
|
104
|
+
def ressource_version(entity)
|
105
|
+
Dir.chdir(abs_klass_dir(entity)) do
|
106
|
+
in_media_directory(entity) do
|
107
|
+
Dir.glob('*').last
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Here as well, MVP implementation
|
113
|
+
def replace_file(data:, filename:, entity:)
|
114
|
+
Dir.chdir(abs_klass_dir(entity)) do
|
115
|
+
in_media_directory(entity) do
|
116
|
+
version = nil
|
117
|
+
Dir.glob('*').each do |oldf|
|
118
|
+
version = oldf
|
119
|
+
File.delete(oldf)
|
120
|
+
end
|
121
|
+
filename = (version.to_i + 1).to_s
|
122
|
+
File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
# Simple static File/Directory based media store handler
|
128
|
+
# similar to Rack::Static
|
129
|
+
# with directory Tree structure
|
130
|
+
|
131
|
+
class StaticTree < Static
|
132
|
+
SEP = '/00/'.freeze
|
133
|
+
VERS = '/v'.freeze
|
134
|
+
|
135
|
+
def self.path_builder(ids)
|
136
|
+
ids.map { |id| id.to_s.chars.join('/') }.join(SEP) << VERS
|
137
|
+
end
|
138
|
+
|
139
|
+
# this is relative to abs_klass_dir(entity) eg to /@root/Photo
|
140
|
+
# tree-structure
|
141
|
+
# media_path_ids = 1 --> 1
|
142
|
+
# media_path_ids = 15 --> 1/5
|
143
|
+
# media_path_ids = 555 --> 5/5/5
|
144
|
+
# media_path_ids = 5,5,5 --> 5/00/5/00/5
|
145
|
+
# media_path_ids = 5,00,5 --> 5/00/0/0/00/5
|
146
|
+
# media_path_ids = 5,xyz,5 --> 5/00/x/y/z/00/5
|
147
|
+
def media_directory(entity)
|
148
|
+
StaticTree.path_builder(entity.media_path_ids)
|
149
|
+
# entity.media_path_ids.map{|id| id.to_s.chars.join('/')}.join(@sep)
|
150
|
+
end
|
151
|
+
|
152
|
+
def in_media_directory(entity)
|
153
|
+
mpi = media_directory(entity)
|
154
|
+
FileUtils.makedirs mpi unless Dir.exist?(mpi)
|
155
|
+
Dir.chdir(mpi) { yield }
|
156
|
+
end
|
157
|
+
|
158
|
+
def odata_delete(entity:)
|
159
|
+
Dir.chdir(abs_klass_dir(entity)) do
|
160
|
+
in_media_directory(entity) do
|
161
|
+
Dir.glob('*').each { |oldf| File.delete(oldf) if File.file?(oldf) }
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# 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
|
+
def replace_file(data:, filename:, entity:)
|
177
|
+
Dir.chdir(abs_klass_dir(entity)) do
|
178
|
+
in_media_directory(entity) do
|
179
|
+
version = nil
|
180
|
+
Dir.glob('*').each do |oldf|
|
181
|
+
version = oldf
|
182
|
+
File.delete(oldf)
|
183
|
+
end
|
184
|
+
filename = (version.to_i + 1).to_s
|
185
|
+
File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# special handling for media entity
|
193
|
+
module EntityClassMedia
|
194
|
+
attr_reader :media_handler
|
195
|
+
attr_reader :slug_field
|
196
|
+
|
197
|
+
# API method for defining the media handler
|
198
|
+
# eg.
|
199
|
+
# publish_media_model photos do
|
200
|
+
# use OData::Media::Static, :root => '/media_root'
|
201
|
+
# end
|
202
|
+
|
203
|
+
def set_default_media_handler
|
204
|
+
@media_handler = OData::Media::Static.new
|
205
|
+
end
|
206
|
+
|
207
|
+
def use(klass, args)
|
208
|
+
@media_handler = klass.new(**args)
|
209
|
+
end
|
210
|
+
|
211
|
+
# API method for setting the model field mapped to SLUG on upload
|
212
|
+
def slug(inp)
|
213
|
+
@slug_field = inp
|
214
|
+
end
|
215
|
+
|
216
|
+
def api_check_media_fields
|
217
|
+
raise(OData::API::MediaModelError, self) unless db_schema.key?(:content_type)
|
218
|
+
|
219
|
+
# unless self.db_schema.has_key?(:media_src)
|
220
|
+
# raise OData::API::MediaModelError, self
|
221
|
+
# end
|
222
|
+
end
|
223
|
+
|
224
|
+
# END API methods
|
225
|
+
|
226
|
+
def new_media_entity(mimetype:)
|
227
|
+
nh = { 'content_type' => mimetype }
|
228
|
+
new_from_hson_h(nh)
|
229
|
+
end
|
230
|
+
|
231
|
+
# POST for media entity collection --> Create media-entity linked to
|
232
|
+
# uploaded file from payload
|
233
|
+
# 1. create new media entity
|
234
|
+
# 2. get the pk/id from the new media entity
|
235
|
+
# 3. Upload the file and use the pk/id to get an unique Directory/filename
|
236
|
+
# assignment to the media entity record
|
237
|
+
# NOTE: we will implement this first in a MVP way. There will be plenty of
|
238
|
+
# potential future enhancements (performance, scallability, flexibility... with added complexity)
|
239
|
+
# 4. create relation to parent if needed
|
240
|
+
def odata_create_entity_and_relation(req, assoc, parent)
|
241
|
+
req.with_media_data do |data, mimetype, filename|
|
242
|
+
## future enhancement: validate allowed mimetypes ?
|
243
|
+
# if (invalid = invalid_media_mimetype(mimetype))
|
244
|
+
# ::OData::Request::ON_CGST_ERROR.call(req)
|
245
|
+
# return [422, {}, ['Invalid mime type: ', invalid.to_s]]
|
246
|
+
# end
|
247
|
+
|
248
|
+
if req.accept?(APPJSON)
|
249
|
+
|
250
|
+
new_entity = new_media_entity(mimetype: mimetype)
|
251
|
+
|
252
|
+
if slug_field
|
253
|
+
|
254
|
+
new_entity.set_fields({ slug_field => filename },
|
255
|
+
data_fields,
|
256
|
+
missing: :skip)
|
257
|
+
end
|
258
|
+
|
259
|
+
# to_one rels are create with FK data set on the parent entity
|
260
|
+
if parent
|
261
|
+
odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
|
262
|
+
else
|
263
|
+
# in-changeset requests get their own transaction
|
264
|
+
new_entity.save(transaction: !req.in_changeset)
|
265
|
+
end
|
266
|
+
|
267
|
+
req.register_content_id_ref(new_entity)
|
268
|
+
new_entity.copy_request_infos(req)
|
269
|
+
|
270
|
+
media_handler.save_file(data: data, entity: new_entity, filename: filename)
|
271
|
+
|
272
|
+
[201, CT_JSON, new_entity.to_odata_post_json(service: req.service)]
|
273
|
+
else # TODO: other formats
|
274
|
+
415
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
@@ -1,18 +1,32 @@
|
|
1
1
|
require 'odata/error.rb'
|
2
2
|
|
3
|
-
# Ordering with ruby expression
|
4
|
-
module OrderWithRuby
|
5
|
-
# this module requires the @fn attribute to exist where it is used
|
6
|
-
def fn=(fnam)
|
7
|
-
@fn = fnam
|
8
|
-
@fn_tab = fnam.split('/').map(&:to_sym)
|
9
|
-
end
|
10
|
-
end
|
11
|
-
|
12
3
|
# all ordering related classes in our OData module
|
13
4
|
module OData
|
14
5
|
# base class for ordering
|
15
|
-
class
|
6
|
+
class OrderBase
|
7
|
+
# re-useable empty ordering (idempotent)
|
8
|
+
EmptyOrder = new
|
9
|
+
|
10
|
+
# input : the OData order string
|
11
|
+
# returns a Order object that should have a apply_to(cx) method
|
12
|
+
def self.factory(orderstr, jh)
|
13
|
+
orderstr.nil? ? EmptyOrder : MultiOrder.new(orderstr, jh)
|
14
|
+
end
|
15
|
+
|
16
|
+
def empty?
|
17
|
+
true
|
18
|
+
end
|
19
|
+
|
20
|
+
def parse_error?
|
21
|
+
false
|
22
|
+
end
|
23
|
+
|
24
|
+
def apply_to_dataset(dtcx)
|
25
|
+
dtcx
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class Order < OrderBase
|
16
30
|
attr_reader :oarg
|
17
31
|
def initialize(ostr, jh)
|
18
32
|
ostr.strip!
|
@@ -21,23 +35,14 @@ module OData
|
|
21
35
|
build_oarg if @orderp
|
22
36
|
end
|
23
37
|
|
24
|
-
|
25
|
-
|
26
|
-
end
|
27
|
-
|
28
|
-
# input : the filter string
|
29
|
-
# returns a filter object that should have a apply_to(cx) method
|
30
|
-
def self.new_by_parse(orderstr, jh)
|
31
|
-
Order.new_full_match_complexpr(orderstr, jh)
|
32
|
-
end
|
33
|
-
|
34
|
-
# handle with Sequel
|
35
|
-
def self.new_full_match_complexpr(orderstr, jh)
|
36
|
-
ComplexOrder.new(orderstr, jh)
|
38
|
+
def empty?
|
39
|
+
false
|
37
40
|
end
|
38
41
|
|
39
42
|
def apply_to_dataset(dtcx)
|
40
|
-
|
43
|
+
# Warning, we need order_append, simply order(oarg) overwrites
|
44
|
+
# previous one !
|
45
|
+
dtcx.order_append(@oarg)
|
41
46
|
end
|
42
47
|
|
43
48
|
def build_oarg
|
@@ -60,26 +65,31 @@ module OData
|
|
60
65
|
end
|
61
66
|
|
62
67
|
# complex ordering logic
|
63
|
-
class
|
68
|
+
class MultiOrder < Order
|
64
69
|
def initialize(orderstr, jh)
|
65
70
|
super
|
66
71
|
@olist = []
|
67
72
|
@jh = jh
|
68
|
-
|
69
|
-
|
70
|
-
@olist = orderstr.split(',').map do |ostr|
|
71
|
-
oo = Order.new(ostr, @jh)
|
72
|
-
oo.oarg
|
73
|
-
end
|
73
|
+
@orderstr = orderstr.dup
|
74
|
+
@olist = orderstr.split(',').map { |ostr| Order.new(ostr, @jh) }
|
74
75
|
end
|
75
76
|
|
76
77
|
def apply_to_dataset(dtcx)
|
77
|
-
@olist.each { |
|
78
|
-
# Warning, we need order_append, simply order(oarg) overwrites
|
79
|
-
# previous one !
|
80
|
-
dtcx = dtcx.order_append(oarg)
|
81
|
-
}
|
78
|
+
@olist.each { |osingl| dtcx = osingl.apply_to_dataset(dtcx) }
|
82
79
|
dtcx
|
83
80
|
end
|
81
|
+
|
82
|
+
def parse_error?
|
83
|
+
@orderstr.split(',').each do |pord|
|
84
|
+
pord.strip!
|
85
|
+
qualfn, dir = pord.split(/\s/)
|
86
|
+
qualfn.strip!
|
87
|
+
dir.strip! if dir
|
88
|
+
return true unless @jh.start_model.attrib_path_valid? qualfn
|
89
|
+
return true unless [nil, 'asc', 'desc'].include? dir
|
90
|
+
end
|
91
|
+
|
92
|
+
false
|
93
|
+
end
|
84
94
|
end
|
85
95
|
end
|