safrano 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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