safrano 0.3.2 → 0.3.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/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
|