safrano 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/odata/batch.rb +190 -0
- data/lib/odata/collection.rb +408 -0
- data/lib/odata/collection_filter.rb +447 -0
- data/lib/odata/collection_order.rb +91 -0
- data/lib/odata/entity.rb +267 -0
- data/lib/odata/error.rb +86 -0
- data/lib/odata/relations.rb +79 -0
- data/lib/odata/walker.rb +100 -0
- metadata +24 -2
data/lib/odata/entity.rb
ADDED
@@ -0,0 +1,267 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# !/usr/bin/env ruby
|
3
|
+
|
4
|
+
require 'json'
|
5
|
+
require 'pp'
|
6
|
+
require 'rexml/document'
|
7
|
+
require 'safrano.rb'
|
8
|
+
require 'odata/collection.rb' # required for self.class.entity_type_name ??
|
9
|
+
|
10
|
+
module OData
|
11
|
+
# this will be mixed in the Model classes (subclasses of Sequel Model)
|
12
|
+
module Entity
|
13
|
+
attr_reader :params
|
14
|
+
attr_reader :uribase
|
15
|
+
|
16
|
+
# methods related to transitions to next state (cf. walker)
|
17
|
+
module Transitions
|
18
|
+
def allowed_transitions
|
19
|
+
aurgx = self.class.attribute_url_regexp
|
20
|
+
alltr = [
|
21
|
+
Safrano::TransitionEnd,
|
22
|
+
Safrano::TransitionCount,
|
23
|
+
Safrano::Transition.new(%r{\A/(#{aurgx})(.*)\z},
|
24
|
+
trans: 'transition_attribute')
|
25
|
+
]
|
26
|
+
if (ncurgx = self.class.nav_collection_url_regexp)
|
27
|
+
alltr <<
|
28
|
+
Safrano::Transition.new(%r{\A/(#{ncurgx})(.*)\z},
|
29
|
+
trans: 'transition_nav_collection')
|
30
|
+
|
31
|
+
end
|
32
|
+
if (neurgx = self.class.nav_entity_url_regexp)
|
33
|
+
alltr <<
|
34
|
+
Safrano::Transition.new(%r{\A/(#{neurgx})(.*)\z},
|
35
|
+
trans: 'transition_nav_entity')
|
36
|
+
end
|
37
|
+
alltr
|
38
|
+
end
|
39
|
+
|
40
|
+
def transition_end(_match_result)
|
41
|
+
[nil, :end]
|
42
|
+
end
|
43
|
+
|
44
|
+
def transition_count(_match_result)
|
45
|
+
[self, :end]
|
46
|
+
end
|
47
|
+
|
48
|
+
def transition_attribute(match_result)
|
49
|
+
attrib = match_result[1]
|
50
|
+
[values[attrib.to_sym], :run]
|
51
|
+
end
|
52
|
+
|
53
|
+
def transition_nav_collection(match_result)
|
54
|
+
attrib = match_result[1]
|
55
|
+
[get_related(attrib), :run]
|
56
|
+
# attr_klass = self.class.nav_collection_attribs[attrib].to_s
|
57
|
+
# [get_related(attr_klass), :run]
|
58
|
+
end
|
59
|
+
|
60
|
+
def transition_nav_entity(match_result)
|
61
|
+
attrib = match_result[1]
|
62
|
+
[get_related_entity(attrib), :run]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
include Transitions
|
67
|
+
|
68
|
+
def nav_values
|
69
|
+
@nav_values = {}
|
70
|
+
|
71
|
+
if self.class.nav_entity_attribs
|
72
|
+
self.class.nav_entity_attribs.each_key do |na_str|
|
73
|
+
@nav_values[na_str.to_sym] = send(na_str)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
@nav_values
|
77
|
+
end
|
78
|
+
|
79
|
+
def nav_coll
|
80
|
+
@nav_coll = {}
|
81
|
+
if self.class.nav_collection_attribs
|
82
|
+
self.class.nav_collection_attribs.each_key do |nc_str|
|
83
|
+
@nav_coll[nc_str.to_sym] = send(nc_str)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
@nav_coll
|
87
|
+
end
|
88
|
+
|
89
|
+
def uri_path
|
90
|
+
kla = self.class
|
91
|
+
"#{kla.entity_set_name}(#{pk_uri})"
|
92
|
+
end
|
93
|
+
|
94
|
+
def uri(uriba)
|
95
|
+
"#{uriba}/#{uri_path}"
|
96
|
+
end
|
97
|
+
|
98
|
+
# Json formatter for a single entity (probably OData V1/V2 like)
|
99
|
+
def to_odata_json(service:)
|
100
|
+
{ 'd' => service.get_entity_odata_h(entity: self,
|
101
|
+
expand: @params['$expand'],
|
102
|
+
uribase: @uribase) }.to_json
|
103
|
+
end
|
104
|
+
|
105
|
+
# post paylod expects the new entity in an array
|
106
|
+
def to_odata_post_json(service:)
|
107
|
+
{ 'd' => service.get_coll_odata_h(array: [self],
|
108
|
+
uribase: @uribase) }.to_json
|
109
|
+
end
|
110
|
+
|
111
|
+
def type_name
|
112
|
+
self.class.type_name
|
113
|
+
end
|
114
|
+
|
115
|
+
# metadata for json h
|
116
|
+
def metadata_h(uribase:)
|
117
|
+
{ uri: uri(uribase),
|
118
|
+
type: type_name }
|
119
|
+
end
|
120
|
+
|
121
|
+
# Finally Process REST verbs...
|
122
|
+
def odata_get(req)
|
123
|
+
@params = req.params
|
124
|
+
@uribase = req.uribase
|
125
|
+
if req.accept?('application/json')
|
126
|
+
[200, { 'Content-Type' => 'application/json;charset=utf-8' },
|
127
|
+
to_odata_json(service: req.service)]
|
128
|
+
else # TODO: other formats
|
129
|
+
415
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def odata_delete(req)
|
134
|
+
if req.accept?('application/json')
|
135
|
+
delete
|
136
|
+
[200, { 'Content-Type' => 'application/json;charset=utf-8' },
|
137
|
+
{ 'd' => req.service.get_emptycoll_odata_h }.to_json]
|
138
|
+
else # TODO: other formats
|
139
|
+
415
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def odata_post(req)
|
144
|
+
# TODO: check Request body format...
|
145
|
+
data = JSON.parse(req.body.read)['d']
|
146
|
+
@uribase = req.uribase
|
147
|
+
|
148
|
+
if req.accept?('application/json')
|
149
|
+
data.delete('__metadata')
|
150
|
+
|
151
|
+
update_fields(data, self.class.data_fields, missing: :skip)
|
152
|
+
|
153
|
+
[202, to_odata_post_json(service: req.service)]
|
154
|
+
else # TODO: other formats
|
155
|
+
415
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def odata_patch(req)
|
160
|
+
# TODO: check Request body format...
|
161
|
+
|
162
|
+
data = JSON.parse(req.body.read)['d']
|
163
|
+
@uribase = req.uribase
|
164
|
+
|
165
|
+
if req.accept?('application/json')
|
166
|
+
data.delete('__metadata')
|
167
|
+
update_fields(data, self.class.data_fields, missing: :skip)
|
168
|
+
# patch should return 204 + no content
|
169
|
+
[204, []]
|
170
|
+
else # TODO: other formats
|
171
|
+
415
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# redefinitions of the main methods for a navigated collection
|
176
|
+
# (eg. all Books of Author[2] is Author[2].Books.all )
|
177
|
+
module NavigationRedefinitions
|
178
|
+
def all
|
179
|
+
@child_method.call
|
180
|
+
end
|
181
|
+
|
182
|
+
def count
|
183
|
+
@child_method.call.count
|
184
|
+
end
|
185
|
+
|
186
|
+
# TODO: fix design
|
187
|
+
def navigated_coll
|
188
|
+
true
|
189
|
+
end
|
190
|
+
|
191
|
+
def navigated_dataset
|
192
|
+
@child_dataset_method.call
|
193
|
+
end
|
194
|
+
|
195
|
+
# TODO: this is designed by my left foot. maybe my right one can do better
|
196
|
+
# at least it does what it should (testunit passed)
|
197
|
+
def [](*args)
|
198
|
+
y = @child_method.call
|
199
|
+
return nil unless (found = super(args))
|
200
|
+
|
201
|
+
# y.find { |e| e.pk_val == found.pk_val }
|
202
|
+
y.find { |e| e.values == found.values }
|
203
|
+
end
|
204
|
+
|
205
|
+
def each
|
206
|
+
y = @child_method.call
|
207
|
+
y.each { |enty| yield enty }
|
208
|
+
end
|
209
|
+
|
210
|
+
def to_a
|
211
|
+
y = @child_method.call
|
212
|
+
y.to_a
|
213
|
+
end
|
214
|
+
end
|
215
|
+
# GetRelated that returns a anonymous Class (ie. representing a collection)
|
216
|
+
# subtype of the related object Class ( childklass )
|
217
|
+
# (...to_many relationship )
|
218
|
+
def get_related(childattrib)
|
219
|
+
parent = self
|
220
|
+
childklass = self.class.nav_collection_attribs[childattrib]
|
221
|
+
Class.new(childklass) do
|
222
|
+
# this makes use of Sequel's Model relationships; eg this is
|
223
|
+
# 'Race[12].Edition'
|
224
|
+
# where Race[12] would be our self and 'Edition' is the
|
225
|
+
# childattrib(collection)
|
226
|
+
@child_method = parent.method(childattrib.to_sym)
|
227
|
+
@child_dataset_method = parent.method("#{childattrib}_dataset".to_sym)
|
228
|
+
prepare_pk
|
229
|
+
prepare_fields
|
230
|
+
# Now in this anonymous Class we can refine the "all, count and []
|
231
|
+
# methods, to take into account the relationship
|
232
|
+
extend NavigationRedefinitions
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
# GetRelatedEntity that returns an single related Entity
|
237
|
+
# (...to_one relationship )
|
238
|
+
def get_related_entity(childattrib)
|
239
|
+
# this makes use of Sequel's Model relationships; eg this is
|
240
|
+
# 'Race[12].RaceType'
|
241
|
+
# where Race[12] would be our self and 'RaceType' is the single
|
242
|
+
# childattrib entity
|
243
|
+
child_method = method(childattrib.to_sym)
|
244
|
+
child_method.call
|
245
|
+
end
|
246
|
+
end
|
247
|
+
# end of module ODataEntity
|
248
|
+
|
249
|
+
# for a single public key
|
250
|
+
module EntitySinglePK
|
251
|
+
include Entity
|
252
|
+
def pk_uri
|
253
|
+
pk
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
# for multiple key
|
258
|
+
module EntityMultiPK
|
259
|
+
include Entity
|
260
|
+
def pk_uri
|
261
|
+
# self.class.primary_key is provided by Sequel as
|
262
|
+
# array of symbols
|
263
|
+
self.class.primary_key.map { |pk| "#{pk}='#{values[pk]}'" }.join(',')
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
# end of Module OData
|
data/lib/odata/error.rb
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'pp'
|
5
|
+
require 'rexml/document'
|
6
|
+
require 'safrano.rb'
|
7
|
+
|
8
|
+
# Error handling
|
9
|
+
module OData
|
10
|
+
# for errors occurring in API (publishing) usage --> Exceptions
|
11
|
+
module API
|
12
|
+
# when published class is not a Sequel Model
|
13
|
+
class ModelNameError < NameError
|
14
|
+
def initialize(name)
|
15
|
+
super("class #{name} is not a Sequel Model", name)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
# when published association was not defined on Sequel level
|
19
|
+
class ModelAssociationNameError < NameError
|
20
|
+
def initialize(klass, symb)
|
21
|
+
symbname = symb.to_s
|
22
|
+
msg = "There is no association :#{symbname} defined in class #{klass}"
|
23
|
+
super(msg, symbname)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# base module for HTTP errors
|
29
|
+
module Error
|
30
|
+
def odata_get(req)
|
31
|
+
if req.accept?('application/json')
|
32
|
+
[const_get(:HTTP_CODE),
|
33
|
+
{ 'Content-Type' => 'application/json;charset=utf-8' },
|
34
|
+
{ 'odata.error' => { 'code' => '', 'message' => @msg } }.to_json]
|
35
|
+
else
|
36
|
+
[const_get(:HTTP_CODE),
|
37
|
+
{ 'Content-Type' => 'text/plain;charset=utf-8' }, @msg]
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
# http Bad Req.
|
42
|
+
class BadRequestError
|
43
|
+
extend Error
|
44
|
+
HTTP_CODE = 400
|
45
|
+
@msg = 'Bad Request Error'
|
46
|
+
end
|
47
|
+
# for Syntax error in Filtering
|
48
|
+
class BadRequestFilterParseError < BadRequestError
|
49
|
+
HTTP_CODE = 400
|
50
|
+
@msg = 'Bad Request: Syntax error in Filter'
|
51
|
+
end
|
52
|
+
# http not found
|
53
|
+
class ErrorNotFound
|
54
|
+
extend Error
|
55
|
+
HTTP_CODE = 404
|
56
|
+
@msg = 'The requested ressource was not found'
|
57
|
+
end
|
58
|
+
# Transition error (Safrano specific)
|
59
|
+
class ServerTransitionError
|
60
|
+
extend Error
|
61
|
+
HTTP_CODE = 500
|
62
|
+
@msg = 'Server error: Segment could not be parsed'
|
63
|
+
end
|
64
|
+
# generic http 500 server err
|
65
|
+
class ServerError
|
66
|
+
extend Error
|
67
|
+
HTTP_CODE = 500
|
68
|
+
@msg = 'Server error'
|
69
|
+
end
|
70
|
+
# not implemented (Safrano specific)
|
71
|
+
class NotImplementedError
|
72
|
+
extend Error
|
73
|
+
HTTP_CODE = 501
|
74
|
+
end
|
75
|
+
# batch not implemented (Safrano specific)
|
76
|
+
class BatchNotImplementedError
|
77
|
+
extend Error
|
78
|
+
HTTP_CODE = 501
|
79
|
+
@msg = 'Not implemented: OData batch'
|
80
|
+
end
|
81
|
+
# error in filter parsing (Safrano specific)
|
82
|
+
class FilterParseError < BadRequestError
|
83
|
+
extend Error
|
84
|
+
HTTP_CODE = 400
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
require 'pp'
|
5
|
+
|
6
|
+
# OData relation related classes/module
|
7
|
+
module OData
|
8
|
+
# we represent a relation as a Set (unordered) of two end elements
|
9
|
+
class Relation < Set
|
10
|
+
# attr_reader :rid
|
11
|
+
attr_accessor :multiplicity
|
12
|
+
|
13
|
+
def initialize(arg)
|
14
|
+
super(arg)
|
15
|
+
@multiplicity = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
def sa
|
19
|
+
sort
|
20
|
+
end
|
21
|
+
|
22
|
+
# we need a from/to order independant ID
|
23
|
+
def rid
|
24
|
+
OData::RelationManager.build_id(self)
|
25
|
+
end
|
26
|
+
|
27
|
+
# we need a from/to order independant OData like name
|
28
|
+
def name
|
29
|
+
x = sa.map(&:to_s)
|
30
|
+
y = x.reverse
|
31
|
+
[x.join('_'), y.join('_')].join('_')
|
32
|
+
end
|
33
|
+
|
34
|
+
def each_endobj
|
35
|
+
tmp = to_a.sort
|
36
|
+
yield tmp.first
|
37
|
+
yield tmp.last
|
38
|
+
end
|
39
|
+
|
40
|
+
def set_multiplicity(obj, mult)
|
41
|
+
ms = mult.to_s
|
42
|
+
raise ArgumentError unless include?(obj)
|
43
|
+
|
44
|
+
case ms
|
45
|
+
when '1', '*', '0..1'
|
46
|
+
@multiplicity[obj] = ms
|
47
|
+
else
|
48
|
+
raise ArgumentError
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# some usefull stuff
|
54
|
+
class RelationManager
|
55
|
+
def initialize
|
56
|
+
@list = {}
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.build_id(arg)
|
60
|
+
arg.sort.map(&:to_s).join('_')
|
61
|
+
end
|
62
|
+
|
63
|
+
def each_rel
|
64
|
+
raise ArgumentError unless block_given?
|
65
|
+
|
66
|
+
@list.each { |_rid, rel| yield rel }
|
67
|
+
end
|
68
|
+
|
69
|
+
def get(arg)
|
70
|
+
rid = OData::RelationManager.build_id(arg)
|
71
|
+
if @list.key?(rid)
|
72
|
+
@list[rid]
|
73
|
+
else
|
74
|
+
rel = OData::Relation.new(arg)
|
75
|
+
@list[rid] = rel
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
data/lib/odata/walker.rb
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'pp'
|
5
|
+
require 'rexml/document'
|
6
|
+
require 'safrano.rb'
|
7
|
+
|
8
|
+
module OData
|
9
|
+
# handle navigation in the Datamodel tree of entities/attributes
|
10
|
+
# input is the url path. Url parameters ($filter etc...) are NOT handled here
|
11
|
+
# This uses a state transition algorithm
|
12
|
+
class Walker
|
13
|
+
attr_accessor :contexts
|
14
|
+
attr_accessor :context
|
15
|
+
attr_accessor :end_context
|
16
|
+
attr_reader :path_start
|
17
|
+
attr_accessor :path_remain
|
18
|
+
attr_accessor :path_done
|
19
|
+
attr_accessor :status
|
20
|
+
attr_accessor :error
|
21
|
+
|
22
|
+
# is $count requested?
|
23
|
+
attr_accessor :do_count
|
24
|
+
|
25
|
+
def initialize(service, path)
|
26
|
+
path = URI.decode_www_form_component(path)
|
27
|
+
@context = service
|
28
|
+
@contexts = [@context]
|
29
|
+
@path_start = @path_remain = if service
|
30
|
+
unprefixed(service.xpath_prefix, path)
|
31
|
+
else # This is for batch function
|
32
|
+
path
|
33
|
+
end
|
34
|
+
@path_done = ''
|
35
|
+
@status = :start
|
36
|
+
@end_context = nil
|
37
|
+
@do_count = nil
|
38
|
+
eo
|
39
|
+
end
|
40
|
+
|
41
|
+
def unprefixed(prefix, path)
|
42
|
+
if (prefix == '') || (prefix == '/')
|
43
|
+
path
|
44
|
+
else
|
45
|
+
path.sub!(/\A#{prefix}/, '')
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def get_next_transition
|
50
|
+
# this does not work if there are multiple valid transitions
|
51
|
+
# like when we have attributes that are substring of each other
|
52
|
+
# --> instead of using detect (ie take first transition)
|
53
|
+
# we need to use select and then find the longest match
|
54
|
+
# tr_next = @context.allowed_transitions.detect do |t|
|
55
|
+
# t.do_match(@path_remain)
|
56
|
+
# end
|
57
|
+
|
58
|
+
valid_tr = @context.allowed_transitions.select do |t|
|
59
|
+
t.do_match(@path_remain)
|
60
|
+
end
|
61
|
+
# this is a very fragile and obscure but required hack (wanted: a
|
62
|
+
# better one) to make attributes that are substrings of each other
|
63
|
+
# work well
|
64
|
+
@tr_next = if valid_tr
|
65
|
+
if valid_tr.size == 1
|
66
|
+
valid_tr.first
|
67
|
+
elsif valid_tr.size > 1
|
68
|
+
valid_tr.max_by { |t| t.match_result[1].size }
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def eo
|
74
|
+
while @context
|
75
|
+
get_next_transition
|
76
|
+
if @tr_next
|
77
|
+
@context, @status, @error = @tr_next.do_transition(@context)
|
78
|
+
@contexts << @context
|
79
|
+
@path_remain = @tr_next.path_remain
|
80
|
+
@path_done << @tr_next.path_done
|
81
|
+
# little hack
|
82
|
+
if @status == :end_with_count
|
83
|
+
@do_count = true
|
84
|
+
@status == :end
|
85
|
+
end
|
86
|
+
else
|
87
|
+
@context = nil
|
88
|
+
@status = :error
|
89
|
+
# @error = OData::ServerError
|
90
|
+
@error = OData::ErrorNotFound
|
91
|
+
end
|
92
|
+
|
93
|
+
end
|
94
|
+
# TODO: shouldnt we raise an error here if @status != :end ?
|
95
|
+
return false unless @status == :end
|
96
|
+
|
97
|
+
@end_context = @contexts.size >= 2 ? @contexts[-2] : @contexts[1]
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|