safrano 0.3.3 → 0.4.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.
- checksums.yaml +4 -4
- data/lib/odata/attribute.rb +9 -8
- data/lib/odata/batch.rb +8 -8
- data/lib/odata/collection.rb +239 -92
- data/lib/odata/collection_filter.rb +40 -9
- data/lib/odata/collection_media.rb +159 -28
- data/lib/odata/collection_order.rb +46 -36
- data/lib/odata/common_logger.rb +37 -12
- data/lib/odata/entity.rb +188 -99
- data/lib/odata/error.rb +60 -12
- data/lib/odata/expand.rb +123 -0
- data/lib/odata/filter/base.rb +66 -0
- data/lib/odata/filter/error.rb +33 -0
- data/lib/odata/filter/parse.rb +6 -12
- data/lib/odata/filter/sequel.rb +42 -29
- data/lib/odata/filter/sequel_function_adapter.rb +147 -0
- data/lib/odata/filter/token.rb +5 -1
- data/lib/odata/filter/tree.rb +45 -29
- data/lib/odata/navigation_attribute.rb +60 -27
- 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 +6 -6
- data/lib/safrano.rb +23 -13
- data/lib/{safrano_core.rb → safrano/core.rb} +12 -4
- data/lib/{multipart.rb → safrano/multipart.rb} +17 -26
- data/lib/{odata_rack_builder.rb → safrano/odata_rack_builder.rb} +0 -1
- data/lib/{rack_app.rb → safrano/rack_app.rb} +12 -10
- data/lib/{request.rb → safrano/request.rb} +8 -14
- 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} +162 -131
- data/lib/safrano/version.rb +3 -0
- data/lib/sequel/plugins/join_by_paths.rb +11 -10
- metadata +33 -16
- data/lib/version.rb +0 -4
@@ -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
|
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'rack'
|
2
|
+
require 'fileutils'
|
2
3
|
require_relative './navigation_attribute.rb'
|
3
4
|
|
4
5
|
module OData
|
@@ -9,61 +10,178 @@ module OData
|
|
9
10
|
|
10
11
|
# Simple static File/Directory based media store handler
|
11
12
|
# similar to Rack::Static
|
13
|
+
# with a flat directory structure
|
12
14
|
class Static < Handler
|
13
15
|
def initialize(root: nil)
|
14
16
|
@root = File.absolute_path(root || Dir.pwd)
|
15
17
|
@file_server = ::Rack::File.new(@root)
|
16
18
|
end
|
17
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
|
+
|
18
26
|
# minimal working implementation...
|
19
27
|
# Note: @file_server works relative to @root directory
|
20
28
|
def odata_get(request:, entity:)
|
21
29
|
media_env = request.env.dup
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
# entity
|
26
|
-
|
27
|
-
|
28
|
-
File.join(path(entity), filename)
|
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
|
29
35
|
end
|
30
|
-
|
31
|
-
@file_server.call(media_env)
|
36
|
+
fsret
|
32
37
|
end
|
33
38
|
|
34
|
-
# TODO perf
|
39
|
+
# TODO: [perf] this can be precalculated and cached on MediaModelKlass level
|
35
40
|
# and passed as argument to save_file
|
41
|
+
# eg. /@root/Photo
|
36
42
|
def abs_klass_dir(entity)
|
37
43
|
File.absolute_path(entity.klass_dir, @root)
|
38
44
|
end
|
39
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
|
40
64
|
def abs_path(entity)
|
41
|
-
File.absolute_path(
|
65
|
+
File.absolute_path(media_path(entity), @root)
|
42
66
|
end
|
43
67
|
|
44
|
-
# this is relative to
|
45
|
-
|
46
|
-
|
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
|
47
89
|
end
|
48
90
|
|
49
91
|
# Here as well, MVP implementation
|
50
92
|
def save_file(data:, filename:, entity:)
|
51
|
-
mpi = entity.media_path_id
|
52
93
|
Dir.chdir(abs_klass_dir(entity)) do
|
53
|
-
|
54
|
-
|
94
|
+
in_media_directory(entity) do
|
95
|
+
filename = '1'
|
55
96
|
File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
|
56
97
|
end
|
57
98
|
end
|
58
99
|
end
|
59
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
|
+
|
60
112
|
# Here as well, MVP implementation
|
61
113
|
def replace_file(data:, filename:, entity:)
|
62
|
-
mpi = entity.media_path_id
|
63
114
|
Dir.chdir(abs_klass_dir(entity)) do
|
64
|
-
|
65
|
-
|
66
|
-
Dir.glob('*').each
|
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
|
67
185
|
File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
|
68
186
|
end
|
69
187
|
end
|
@@ -74,6 +192,7 @@ module OData
|
|
74
192
|
# special handling for media entity
|
75
193
|
module EntityClassMedia
|
76
194
|
attr_reader :media_handler
|
195
|
+
attr_reader :slug_field
|
77
196
|
|
78
197
|
# API method for defining the media handler
|
79
198
|
# eg.
|
@@ -85,22 +204,27 @@ module OData
|
|
85
204
|
@media_handler = OData::Media::Static.new
|
86
205
|
end
|
87
206
|
|
88
|
-
def use(klass,
|
89
|
-
@media_handler = klass.new(
|
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
|
90
214
|
end
|
91
215
|
|
92
216
|
def api_check_media_fields
|
93
|
-
unless
|
94
|
-
|
95
|
-
end
|
217
|
+
raise(OData::API::MediaModelError, self) unless db_schema.key?(:content_type)
|
218
|
+
|
96
219
|
# unless self.db_schema.has_key?(:media_src)
|
97
220
|
# raise OData::API::MediaModelError, self
|
98
221
|
# end
|
99
222
|
end
|
100
223
|
|
224
|
+
# END API methods
|
225
|
+
|
101
226
|
def new_media_entity(mimetype:)
|
102
|
-
nh = {}
|
103
|
-
nh['content_type'] = mimetype
|
227
|
+
nh = { 'content_type' => mimetype }
|
104
228
|
new_from_hson_h(nh)
|
105
229
|
end
|
106
230
|
|
@@ -125,6 +249,13 @@ module OData
|
|
125
249
|
|
126
250
|
new_entity = new_media_entity(mimetype: mimetype)
|
127
251
|
|
252
|
+
if slug_field
|
253
|
+
|
254
|
+
new_entity.set_fields({ slug_field => filename },
|
255
|
+
data_fields,
|
256
|
+
missing: :skip)
|
257
|
+
end
|
258
|
+
|
128
259
|
# to_one rels are create with FK data set on the parent entity
|
129
260
|
if parent
|
130
261
|
odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
|
@@ -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
|
data/lib/odata/common_logger.rb
CHANGED
@@ -5,21 +5,46 @@ module Rack
|
|
5
5
|
super
|
6
6
|
end
|
7
7
|
|
8
|
+
# Handle https://github.com/rack/rack/pull/1526
|
9
|
+
# new in Rack 2.2.2 : Format has now 11 placeholders instead of 10
|
10
|
+
|
11
|
+
MSG_FUNC = if FORMAT.count('%') == 10
|
12
|
+
lambda { |env, length, status, began_at|
|
13
|
+
FORMAT % [
|
14
|
+
env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-',
|
15
|
+
env['REMOTE_USER'] || '-',
|
16
|
+
Time.now.strftime('%d/%b/%Y:%H:%M:%S %z'),
|
17
|
+
env[REQUEST_METHOD],
|
18
|
+
env[SCRIPT_NAME] + env[PATH_INFO],
|
19
|
+
env[QUERY_STRING].empty? ? '' : "?#{env[QUERY_STRING]}",
|
20
|
+
env[SERVER_PROTOCOL],
|
21
|
+
status.to_s[0..3],
|
22
|
+
length,
|
23
|
+
Utils.clock_time - began_at
|
24
|
+
]
|
25
|
+
}
|
26
|
+
elsif FORMAT.count('%') == 11
|
27
|
+
lambda { |env, length, status, began_at|
|
28
|
+
FORMAT % [
|
29
|
+
env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-',
|
30
|
+
env['REMOTE_USER'] || '-',
|
31
|
+
Time.now.strftime('%d/%b/%Y:%H:%M:%S %z'),
|
32
|
+
env[REQUEST_METHOD],
|
33
|
+
env[SCRIPT_NAME],
|
34
|
+
env[PATH_INFO],
|
35
|
+
env[QUERY_STRING].empty? ? '' : "?#{env[QUERY_STRING]}",
|
36
|
+
env[SERVER_PROTOCOL],
|
37
|
+
status.to_s[0..3],
|
38
|
+
length,
|
39
|
+
Utils.clock_time - began_at
|
40
|
+
]
|
41
|
+
}
|
42
|
+
end
|
43
|
+
|
8
44
|
def batch_log(env, status, header, began_at)
|
9
45
|
length = extract_content_length(header)
|
10
46
|
|
11
|
-
msg =
|
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
|
-
]
|
47
|
+
msg = MSG_FUNC.call(env, length, status, began_at)
|
23
48
|
|
24
49
|
logger = @logger || env[RACK_ERRORS]
|
25
50
|
# Standard library logger doesn't support write but it supports << which actually
|