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.
- checksums.yaml +4 -4
- data/lib/core_ext/Dir/iter.rb +18 -0
- data/lib/core_ext/Hash/transform.rb +21 -0
- data/lib/core_ext/Integer/edm.rb +13 -0
- data/lib/core_ext/REXML/Document/output.rb +16 -0
- data/lib/core_ext/String/convert.rb +25 -0
- data/lib/core_ext/String/edm.rb +13 -0
- data/lib/core_ext/dir.rb +3 -0
- data/lib/core_ext/hash.rb +3 -0
- data/lib/core_ext/integer.rb +3 -0
- data/lib/core_ext/rexml.rb +3 -0
- data/lib/core_ext/string.rb +5 -0
- data/lib/odata/attribute.rb +15 -10
- data/lib/odata/batch.rb +15 -13
- data/lib/odata/collection.rb +144 -535
- data/lib/odata/collection_filter.rb +47 -40
- data/lib/odata/collection_media.rb +145 -74
- data/lib/odata/collection_order.rb +50 -37
- data/lib/odata/common_logger.rb +36 -34
- data/lib/odata/complex_type.rb +152 -0
- data/lib/odata/edm/primitive_types.rb +184 -0
- data/lib/odata/entity.rb +151 -197
- data/lib/odata/error.rb +175 -32
- data/lib/odata/expand.rb +126 -0
- data/lib/odata/filter/base.rb +74 -0
- data/lib/odata/filter/error.rb +49 -6
- data/lib/odata/filter/parse.rb +44 -36
- data/lib/odata/filter/sequel.rb +136 -67
- data/lib/odata/filter/sequel_function_adapter.rb +148 -0
- data/lib/odata/filter/token.rb +26 -19
- data/lib/odata/filter/tree.rb +113 -63
- data/lib/odata/function_import.rb +168 -0
- data/lib/odata/model_ext.rb +637 -0
- data/lib/odata/navigation_attribute.rb +44 -61
- data/lib/odata/relations.rb +5 -5
- data/lib/odata/select.rb +54 -0
- data/lib/odata/transition.rb +71 -0
- data/lib/odata/url_parameters.rb +128 -37
- data/lib/odata/walker.rb +19 -11
- data/lib/safrano.rb +17 -37
- data/lib/safrano/contract.rb +143 -0
- data/lib/safrano/core.rb +29 -104
- data/lib/safrano/core_ext.rb +13 -0
- data/lib/safrano/deprecation.rb +73 -0
- data/lib/safrano/multipart.rb +39 -43
- data/lib/safrano/rack_app.rb +68 -67
- data/lib/safrano/{odata_rack_builder.rb → rack_builder.rb} +18 -2
- data/lib/safrano/request.rb +102 -51
- data/lib/safrano/response.rb +5 -3
- data/lib/safrano/sequel_join_by_paths.rb +2 -2
- data/lib/safrano/service.rb +264 -220
- data/lib/safrano/version.rb +3 -1
- data/lib/sequel/plugins/join_by_paths.rb +17 -29
- metadata +34 -12
@@ -1,61 +1,68 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
require_relative 'filter/sequel.rb'
|
3
|
+
require 'odata/error'
|
5
4
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
22
|
+
# finalize
|
23
|
+
def finalize(_jh) Contract::OK end
|
24
|
+
|
25
|
+
def empty?
|
26
|
+
true
|
23
27
|
end
|
24
|
-
end
|
25
28
|
|
26
|
-
|
27
|
-
|
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
|
-
|
35
|
+
class FilterByParse < FilterBase
|
36
|
+
attr_reader :filterstr
|
37
|
+
|
38
|
+
def initialize(filterstr)
|
43
39
|
@filterstr = filterstr.dup
|
44
|
-
@ast =
|
45
|
-
@jh = jh
|
40
|
+
@ast = Safrano::Filter::Parser.new(@filterstr).build
|
46
41
|
end
|
47
42
|
|
48
|
-
|
49
|
-
|
50
|
-
|
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
|
54
|
-
@
|
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.
|
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
|
5
|
+
require_relative './navigation_attribute'
|
4
6
|
|
5
|
-
module
|
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
|
-
|
22
|
-
|
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
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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(
|
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('*').
|
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.
|
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(
|
80
|
-
Dir.chdir(abs_klass_dir
|
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
|
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
|
123
|
+
Dir.chdir(@abs_klass_dir) do
|
99
124
|
in_media_directory(entity) do
|
100
|
-
|
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
|
-
|
113
|
-
|
114
|
-
def
|
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.
|
134
|
-
Dir.chdir
|
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(
|
140
|
-
Dir.chdir(abs_klass_dir
|
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
|
186
|
+
Dir.chdir(@abs_klass_dir) do
|
150
187
|
in_media_directory(entity) do
|
151
|
-
|
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
|
209
|
+
# use Safrano::Media::Static, :root => '/media_root'
|
171
210
|
# end
|
172
211
|
|
173
212
|
def set_default_media_handler
|
174
|
-
@media_handler =
|
213
|
+
@media_handler = Safrano::Media::Static.new(mediaklass: self)
|
175
214
|
end
|
176
215
|
|
177
|
-
def
|
178
|
-
@media_handler
|
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
|
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
|
-
# ::
|
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 =>
|
227
|
-
|
228
|
-
|
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
|
-
|
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
|
-
|
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
|