safrano 0.0.1 → 0.0.2

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.
@@ -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