safrano 0.3.2 → 0.3.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/multipart.rb +40 -18
- data/lib/odata/batch.rb +17 -3
- data/lib/odata/collection.rb +97 -46
- data/lib/odata/collection_media.rb +148 -0
- data/lib/odata/common_logger.rb +34 -0
- data/lib/odata/entity.rb +159 -38
- data/lib/odata/error.rb +16 -5
- data/lib/odata/navigation_attribute.rb +119 -0
- data/lib/odata/walker.rb +12 -2
- data/lib/odata_rack_builder.rb +1 -1
- data/lib/rack_app.rb +12 -7
- data/lib/request.rb +15 -3
- data/lib/safrano.rb +1 -0
- data/lib/safrano_core.rb +2 -1
- data/lib/service.rb +41 -12
- data/lib/version.rb +4 -0
- metadata +6 -2
@@ -0,0 +1,148 @@
|
|
1
|
+
require 'rack'
|
2
|
+
require_relative './navigation_attribute.rb'
|
3
|
+
|
4
|
+
module OData
|
5
|
+
module Media
|
6
|
+
# base class for Media Handler
|
7
|
+
class Handler
|
8
|
+
end
|
9
|
+
|
10
|
+
# Simple static File/Directory based media store handler
|
11
|
+
# similar to Rack::Static
|
12
|
+
class Static < Handler
|
13
|
+
def initialize(root: nil)
|
14
|
+
@root = File.absolute_path(root || Dir.pwd)
|
15
|
+
@file_server = ::Rack::File.new(@root)
|
16
|
+
end
|
17
|
+
|
18
|
+
# minimal working implementation...
|
19
|
+
# Note: @file_server works relative to @root directory
|
20
|
+
def odata_get(request:, entity:)
|
21
|
+
media_env = request.env.dup
|
22
|
+
relpath = Dir.chdir(abs_path(entity)) do
|
23
|
+
# simple design: one file per directory, and the directory
|
24
|
+
# contains the media entity-id --> implicit link between the media
|
25
|
+
# entity
|
26
|
+
filename = Dir.glob('*').first
|
27
|
+
|
28
|
+
File.join(path(entity), filename)
|
29
|
+
end
|
30
|
+
media_env['PATH_INFO'] = relpath
|
31
|
+
@file_server.call(media_env)
|
32
|
+
end
|
33
|
+
|
34
|
+
# TODO perf: this can be precalculated and cached on MediaModelKlass level
|
35
|
+
# and passed as argument to save_file
|
36
|
+
def abs_klass_dir(entity)
|
37
|
+
File.absolute_path(entity.klass_dir, @root)
|
38
|
+
end
|
39
|
+
|
40
|
+
def abs_path(entity)
|
41
|
+
File.absolute_path(path(entity), @root)
|
42
|
+
end
|
43
|
+
|
44
|
+
# this is relative to @root
|
45
|
+
def path(entity)
|
46
|
+
File.join(entity.klass_dir, entity.media_path_id)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Here as well, MVP implementation
|
50
|
+
def save_file(data:, filename:, entity:)
|
51
|
+
mpi = entity.media_path_id
|
52
|
+
Dir.chdir(abs_klass_dir(entity)) do
|
53
|
+
Dir.mkdir mpi unless Dir.exists?(mpi)
|
54
|
+
Dir.chdir mpi do
|
55
|
+
File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Here as well, MVP implementation
|
61
|
+
def replace_file(data:, filename:, entity:)
|
62
|
+
mpi = entity.media_path_id
|
63
|
+
Dir.chdir(abs_klass_dir(entity)) do
|
64
|
+
Dir.mkdir mpi unless Dir.exists?(mpi)
|
65
|
+
Dir.chdir mpi do
|
66
|
+
Dir.glob('*').each { |oldf| File.delete(oldf) }
|
67
|
+
File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# special handling for media entity
|
75
|
+
module EntityClassMedia
|
76
|
+
attr_reader :media_handler
|
77
|
+
|
78
|
+
# API method for defining the media handler
|
79
|
+
# eg.
|
80
|
+
# publish_media_model photos do
|
81
|
+
# use OData::Media::Static, :root => '/media_root'
|
82
|
+
# end
|
83
|
+
|
84
|
+
def set_default_media_handler
|
85
|
+
@media_handler = OData::Media::Static.new
|
86
|
+
end
|
87
|
+
|
88
|
+
def use(klass, *args)
|
89
|
+
@media_handler = klass.new(*args)
|
90
|
+
end
|
91
|
+
|
92
|
+
def api_check_media_fields
|
93
|
+
unless self.db_schema.has_key?(:content_type)
|
94
|
+
raise OData::API::MediaModelError, self
|
95
|
+
end
|
96
|
+
# unless self.db_schema.has_key?(:media_src)
|
97
|
+
# raise OData::API::MediaModelError, self
|
98
|
+
# end
|
99
|
+
end
|
100
|
+
|
101
|
+
def new_media_entity(mimetype:)
|
102
|
+
nh = {}
|
103
|
+
nh['content_type'] = mimetype
|
104
|
+
new_from_hson_h(nh)
|
105
|
+
end
|
106
|
+
|
107
|
+
# POST for media entity collection --> Create media-entity linked to
|
108
|
+
# uploaded file from payload
|
109
|
+
# 1. create new media entity
|
110
|
+
# 2. get the pk/id from the new media entity
|
111
|
+
# 3. Upload the file and use the pk/id to get an unique Directory/filename
|
112
|
+
# assignment to the media entity record
|
113
|
+
# NOTE: we will implement this first in a MVP way. There will be plenty of
|
114
|
+
# potential future enhancements (performance, scallability, flexibility... with added complexity)
|
115
|
+
# 4. create relation to parent if needed
|
116
|
+
def odata_create_entity_and_relation(req, assoc, parent)
|
117
|
+
req.with_media_data do |data, mimetype, filename|
|
118
|
+
## future enhancement: validate allowed mimetypes ?
|
119
|
+
# if (invalid = invalid_media_mimetype(mimetype))
|
120
|
+
# ::OData::Request::ON_CGST_ERROR.call(req)
|
121
|
+
# return [422, {}, ['Invalid mime type: ', invalid.to_s]]
|
122
|
+
# end
|
123
|
+
|
124
|
+
if req.accept?(APPJSON)
|
125
|
+
|
126
|
+
new_entity = new_media_entity(mimetype: mimetype)
|
127
|
+
|
128
|
+
# to_one rels are create with FK data set on the parent entity
|
129
|
+
if parent
|
130
|
+
odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
|
131
|
+
else
|
132
|
+
# in-changeset requests get their own transaction
|
133
|
+
new_entity.save(transaction: !req.in_changeset)
|
134
|
+
end
|
135
|
+
|
136
|
+
req.register_content_id_ref(new_entity)
|
137
|
+
new_entity.copy_request_infos(req)
|
138
|
+
|
139
|
+
media_handler.save_file(data: data, entity: new_entity, filename: filename)
|
140
|
+
|
141
|
+
[201, CT_JSON, new_entity.to_odata_post_json(service: req.service)]
|
142
|
+
else # TODO: other formats
|
143
|
+
415
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Rack
|
2
|
+
class ODataCommonLogger < CommonLogger
|
3
|
+
def call(env)
|
4
|
+
env['safrano.logger_mw'] = self
|
5
|
+
super
|
6
|
+
end
|
7
|
+
|
8
|
+
def batch_log(env, status, header, began_at)
|
9
|
+
length = extract_content_length(header)
|
10
|
+
|
11
|
+
msg = FORMAT % [
|
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
|
+
]
|
23
|
+
|
24
|
+
logger = @logger || env[RACK_ERRORS]
|
25
|
+
# Standard library logger doesn't support write but it supports << which actually
|
26
|
+
# calls to write on the log device without formatting
|
27
|
+
if logger.respond_to?(:write)
|
28
|
+
logger.write(msg)
|
29
|
+
else
|
30
|
+
logger << msg
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/odata/entity.rb
CHANGED
@@ -5,7 +5,7 @@ require 'odata/collection.rb' # required for self.class.entity_type_name ??
|
|
5
5
|
|
6
6
|
module OData
|
7
7
|
# this will be mixed in the Model classes (subclasses of Sequel Model)
|
8
|
-
module
|
8
|
+
module EntityBase
|
9
9
|
attr_reader :params
|
10
10
|
attr_reader :uribase
|
11
11
|
|
@@ -16,6 +16,7 @@ module OData
|
|
16
16
|
Safrano::TransitionEnd,
|
17
17
|
Safrano::TransitionCount,
|
18
18
|
Safrano::TransitionLinks,
|
19
|
+
Safrano::TransitionValue,
|
19
20
|
Safrano::Transition.new(self.class.transition_attribute_regexp,
|
20
21
|
trans: 'transition_attribute')
|
21
22
|
]
|
@@ -41,6 +42,11 @@ module OData
|
|
41
42
|
[self, :end]
|
42
43
|
end
|
43
44
|
|
45
|
+
def transition_value(_match_result)
|
46
|
+
# $value is only allowd for media entities (or attributes)
|
47
|
+
[self, :end_with_media_value]
|
48
|
+
end
|
49
|
+
|
44
50
|
def transition_links(_match_result)
|
45
51
|
[self, :run_with_links]
|
46
52
|
end
|
@@ -54,8 +60,6 @@ module OData
|
|
54
60
|
def transition_nav_collection(match_result)
|
55
61
|
attrib = match_result[1]
|
56
62
|
[get_related(attrib), :run]
|
57
|
-
# attr_klass = self.class.nav_collection_attribs[attrib].to_s
|
58
|
-
# [get_related(attr_klass), :run]
|
59
63
|
end
|
60
64
|
|
61
65
|
def transition_nav_entity(match_result)
|
@@ -84,21 +88,19 @@ module OData
|
|
84
88
|
@nav_coll
|
85
89
|
end
|
86
90
|
|
87
|
-
def uri_path
|
88
|
-
kla = self.class
|
89
|
-
"#{kla.entity_set_name}(#{pk_uri})"
|
90
|
-
end
|
91
|
-
|
92
91
|
def uri(uriba)
|
93
|
-
"#{uriba}/#{
|
92
|
+
"#{uriba}/#{self.class.entity_set_name}(#{pk_uri})"
|
94
93
|
end
|
95
|
-
|
94
|
+
D = 'd'.freeze
|
95
|
+
DJopen = '{"d":'.freeze
|
96
|
+
DJclose = '}'.freeze
|
96
97
|
# Json formatter for a single entity (probably OData V1/V2 like)
|
97
98
|
def to_odata_json(service:)
|
98
|
-
|
99
|
+
innerj = service.get_entity_odata_h(entity: self,
|
99
100
|
expand: @params['$expand'],
|
100
101
|
# links: @do_links,
|
101
|
-
uribase: @uribase)
|
102
|
+
uribase: @uribase).to_json
|
103
|
+
"#{DJopen}#{innerj}#{DJclose}"
|
102
104
|
end
|
103
105
|
|
104
106
|
# needed for proper datetime output
|
@@ -107,7 +109,7 @@ module OData
|
|
107
109
|
# WARNING; this code is duplicated in attribute.rb
|
108
110
|
# (and the inverted transformation is in test/client.rb)
|
109
111
|
# will require a more systematic solution some day
|
110
|
-
|
112
|
+
values_for_odata.transform_values! { |v|
|
111
113
|
case v
|
112
114
|
when Time
|
113
115
|
# try to get back the database time zone and value
|
@@ -120,20 +122,15 @@ module OData
|
|
120
122
|
|
121
123
|
# post paylod expects the new entity in an array
|
122
124
|
def to_odata_post_json(service:)
|
123
|
-
|
124
|
-
uribase: @uribase)
|
125
|
+
innerj = service.get_coll_odata_h(array: [self],
|
126
|
+
uribase: @uribase).to_json
|
127
|
+
"#{DJopen}#{innerj}#{DJclose}"
|
125
128
|
end
|
126
129
|
|
127
130
|
def type_name
|
128
131
|
self.class.type_name
|
129
132
|
end
|
130
133
|
|
131
|
-
# metadata for json h
|
132
|
-
def metadata_h(uribase:)
|
133
|
-
{ uri: uri(uribase),
|
134
|
-
type: type_name }
|
135
|
-
end
|
136
|
-
|
137
134
|
def copy_request_infos(req)
|
138
135
|
@params = req.params
|
139
136
|
@uribase = req.uribase
|
@@ -144,8 +141,10 @@ module OData
|
|
144
141
|
def odata_get(req)
|
145
142
|
copy_request_infos(req)
|
146
143
|
|
147
|
-
if req.
|
148
|
-
|
144
|
+
if req.walker.media_value
|
145
|
+
odata_media_value_get(req)
|
146
|
+
elsif req.accept?(APPJSON)
|
147
|
+
[200, CT_JSON, [to_odata_json(service: req.service)]]
|
149
148
|
else # TODO: other formats
|
150
149
|
415
|
151
150
|
end
|
@@ -154,12 +153,13 @@ module OData
|
|
154
153
|
def odata_delete(req)
|
155
154
|
if req.accept?(APPJSON)
|
156
155
|
delete
|
157
|
-
[200, CT_JSON, { 'd' => req.service.get_emptycoll_odata_h }.to_json]
|
156
|
+
[200, CT_JSON, [{ 'd' => req.service.get_emptycoll_odata_h }.to_json]]
|
158
157
|
else # TODO: other formats
|
159
158
|
415
|
160
159
|
end
|
161
160
|
end
|
162
161
|
|
162
|
+
# TODO: differentiate between POST/PUT/PATCH/MERGE
|
163
163
|
def odata_post(req)
|
164
164
|
data = JSON.parse(req.body.read)
|
165
165
|
@uribase = req.uribase
|
@@ -174,20 +174,42 @@ module OData
|
|
174
174
|
update_fields(data, self.class.data_fields, missing: :skip)
|
175
175
|
end
|
176
176
|
|
177
|
-
[202, to_odata_post_json(service: req.service)]
|
177
|
+
[202, {}, to_odata_post_json(service: req.service)]
|
178
178
|
else # TODO: other formats
|
179
179
|
415
|
180
180
|
end
|
181
181
|
end
|
182
182
|
|
183
|
+
def odata_put(req)
|
184
|
+
if req.walker.media_value
|
185
|
+
odata_media_value_put(req)
|
186
|
+
else
|
187
|
+
if req.accept?(APPJSON)
|
188
|
+
data = JSON.parse(req.body.read)
|
189
|
+
@uribase = req.uribase
|
190
|
+
data.delete('__metadata')
|
191
|
+
|
192
|
+
if req.in_changeset
|
193
|
+
set_fields(data, self.class.data_fields, missing: :skip)
|
194
|
+
save(transaction: false)
|
195
|
+
else
|
196
|
+
update_fields(data, self.class.data_fields, missing: :skip)
|
197
|
+
end
|
198
|
+
|
199
|
+
[204, {}, []]
|
200
|
+
else # TODO: other formats
|
201
|
+
415
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
183
206
|
def odata_patch(req)
|
184
|
-
|
185
|
-
req.with_parsed_data(on_error: on_error) do |data|
|
207
|
+
req.with_parsed_data do |data|
|
186
208
|
data.delete('__metadata')
|
187
209
|
|
188
210
|
# validate payload column names
|
189
211
|
if (invalid = self.class.invalid_hash_data?(data))
|
190
|
-
|
212
|
+
::OData::Request::ON_CGST_ERROR.call(req)
|
191
213
|
return [422, {}, ['Invalid attribute name: ', invalid.to_s]]
|
192
214
|
end
|
193
215
|
# TODO: check values/types
|
@@ -218,11 +240,6 @@ module OData
|
|
218
240
|
@child_method.call.count
|
219
241
|
end
|
220
242
|
|
221
|
-
# TODO: fix design
|
222
|
-
def navigated_coll
|
223
|
-
true
|
224
|
-
end
|
225
|
-
|
226
243
|
def dataset
|
227
244
|
@child_dataset_method.call
|
228
245
|
end
|
@@ -236,6 +253,14 @@ module OData
|
|
236
253
|
y.each { |enty| yield enty }
|
237
254
|
end
|
238
255
|
|
256
|
+
def type_name
|
257
|
+
superclass.type_name
|
258
|
+
end
|
259
|
+
|
260
|
+
def media_handler
|
261
|
+
superclass.media_handler
|
262
|
+
end
|
263
|
+
|
239
264
|
def to_a
|
240
265
|
y = @child_method.call
|
241
266
|
y.to_a
|
@@ -254,6 +279,8 @@ module OData
|
|
254
279
|
# childattrib(collection)
|
255
280
|
@child_method = parent.method(childattrib.to_sym)
|
256
281
|
@child_dataset_method = parent.method("#{childattrib}_dataset".to_sym)
|
282
|
+
@nav_parent = parent
|
283
|
+
@navattr_reflection = parent.class.association_reflections[childattrib.to_sym]
|
257
284
|
prepare_pk
|
258
285
|
prepare_fields
|
259
286
|
# Now in this anonymous Class we can refine the "all, count and []
|
@@ -269,11 +296,98 @@ module OData
|
|
269
296
|
# 'Race[12].RaceType'
|
270
297
|
# where Race[12] would be our self and 'RaceType' is the single
|
271
298
|
# childattrib entity
|
272
|
-
|
273
|
-
|
299
|
+
|
300
|
+
# when the child attribute is nil (because the FK is NULL on DB)
|
301
|
+
# then we return a Nil... wrapper object. This object then
|
302
|
+
# allows to receive a POST operation that would actually create the nav attribute entity
|
303
|
+
|
304
|
+
ret = method(childattrib.to_sym).call ||
|
305
|
+
OData::NilNavigationAttribute.new(self, childattrib)
|
306
|
+
|
307
|
+
ret
|
274
308
|
end
|
275
309
|
end
|
276
310
|
# end of module ODataEntity
|
311
|
+
module Entity
|
312
|
+
include EntityBase
|
313
|
+
end
|
314
|
+
|
315
|
+
module NonMediaEntity
|
316
|
+
# non media entity metadata for json h
|
317
|
+
def metadata_h(uribase:)
|
318
|
+
{ uri: uri(uribase),
|
319
|
+
type: type_name }
|
320
|
+
end
|
321
|
+
|
322
|
+
def values_for_odata
|
323
|
+
values.dup
|
324
|
+
end
|
325
|
+
|
326
|
+
# in case of a non media entity, we have to return an error on $value request
|
327
|
+
def odata_media_value_get(req)
|
328
|
+
return BadRequestNonMediaValue.odata_get
|
329
|
+
end
|
330
|
+
|
331
|
+
# in case of a non media entity, we have to return an error on $value PUT
|
332
|
+
def odata_media_value_put(req)
|
333
|
+
return BadRequestNonMediaValue.odata_get
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
module MediaEntity
|
338
|
+
# media entity metadata for json h
|
339
|
+
def metadata_h(uribase:)
|
340
|
+
{ uri: uri(uribase),
|
341
|
+
type: type_name,
|
342
|
+
media_src: media_src(uribase),
|
343
|
+
edit_media: media_src(uribase),
|
344
|
+
content_type: @values[:content_type] }
|
345
|
+
end
|
346
|
+
|
347
|
+
def media_src(urbase)
|
348
|
+
"#{uri(urbase)}/$value"
|
349
|
+
end
|
350
|
+
|
351
|
+
# directory where to put/find the media files for this entity-type
|
352
|
+
def klass_dir
|
353
|
+
type_name
|
354
|
+
end
|
355
|
+
|
356
|
+
# # this is just ModelKlass/pk as a single string
|
357
|
+
# def qualified_media_path_id
|
358
|
+
# "#{self.class}/#{media_path_id}"
|
359
|
+
# end
|
360
|
+
|
361
|
+
def values_for_odata
|
362
|
+
ret = values.dup
|
363
|
+
ret.delete(:content_type)
|
364
|
+
ret
|
365
|
+
end
|
366
|
+
|
367
|
+
# real implementation for returning $value for a media entity
|
368
|
+
def odata_media_value_get(req)
|
369
|
+
# delegate to the media handler on collection(ie class) level
|
370
|
+
self.class.media_handler.odata_get(request: req, entity: self)
|
371
|
+
end
|
372
|
+
|
373
|
+
# real implementation for replacing $value for a media entity
|
374
|
+
def odata_media_value_put(req)
|
375
|
+
model = self.class
|
376
|
+
req.with_media_data do |data, mimetype, filename|
|
377
|
+
emdata = { :content_type => mimetype }
|
378
|
+
if req.in_changeset
|
379
|
+
set_fields(emdata, model.data_fields, missing: :skip)
|
380
|
+
save(transaction: false)
|
381
|
+
else
|
382
|
+
update_fields(emdata, model.data_fields, missing: :skip)
|
383
|
+
end
|
384
|
+
model.media_handler.replace_file(data: data,
|
385
|
+
entity: self,
|
386
|
+
filename: filename)
|
387
|
+
[204, {}, []]
|
388
|
+
end
|
389
|
+
end
|
390
|
+
end
|
277
391
|
|
278
392
|
# for a single public key
|
279
393
|
module EntitySinglePK
|
@@ -281,15 +395,22 @@ module OData
|
|
281
395
|
def pk_uri
|
282
396
|
pk
|
283
397
|
end
|
398
|
+
|
399
|
+
def media_path_id
|
400
|
+
pk.to_s
|
401
|
+
end
|
284
402
|
end
|
285
403
|
|
286
404
|
# for multiple key
|
287
405
|
module EntityMultiPK
|
288
406
|
include Entity
|
289
407
|
def pk_uri
|
290
|
-
#
|
291
|
-
|
292
|
-
|
408
|
+
# pk_hash is provided by Sequel
|
409
|
+
self.pk_hash.map { |k, v| "#{k}='#{v}'" }.join(',')
|
410
|
+
end
|
411
|
+
|
412
|
+
def media_path_id
|
413
|
+
self.pk_hash.values.join('_')
|
293
414
|
end
|
294
415
|
end
|
295
416
|
end
|