safrano 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 49dc75cf9bfcdff0a03b38eaada7f0a968a09ab3777327f0c893f8f39fd32d8f
4
+ data.tar.gz: 7103e882037de0e6c89d368ec25cb0f6c6b9ed4be7fc8dff9aa64d0a6107a29d
5
+ SHA512:
6
+ metadata.gz: e7dbd2370716c6e5f323c9fff63394053521f70af27ee613a288e7ca089071818844a09dc4e400cba4fcd0e227e4c7d38b5d07d2e887aa33553cf8ea57a1e5ec
7
+ data.tar.gz: 94b84b19d4d7273a0aef7c4a52348e4e77340f95cf64792e6f47dc1cde8e06ae675ee3bc5cd87d8c58e8e8098b914b24bb4e38818f96f75fd2116f32ae6e0d8b
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rack'
4
+ require 'rack/cors'
5
+
6
+ module Rack
7
+ module OData
8
+ # just a Wrapper to ensure (force?) that mandatory middlewares are acutally
9
+ # used
10
+ class Builder < ::Rack::Builder
11
+ def initialize(default_app = nil, &block)
12
+ super(default_app)
13
+ use ::Rack::Cors
14
+ instance_eval(&block) if block_given?
15
+ use ::Rack::Lint
16
+ use ::Rack::ContentLength
17
+ end
18
+ end
19
+ end
20
+ end
data/lib/rack_app.rb ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rack'
4
+ require_relative 'odata/walker.rb'
5
+ require_relative 'request.rb'
6
+ require_relative 'response.rb'
7
+ require 'pry'
8
+
9
+ module OData
10
+ # handle GET PUT etc
11
+ module MethodHandlers
12
+ def odata_options
13
+ # cf. stackoverflow.com/questions/22924678/sinatra-delete-response-headers
14
+
15
+ x = if @walker.status == :end
16
+ headers.delete('Content-Type')
17
+ @response.headers.delete('Content-Type')
18
+ [200, {}, '']
19
+ else
20
+ odata_error
21
+ end
22
+ @response.headers['Content-Type'] = ''
23
+ x
24
+ end
25
+
26
+ def odata_error
27
+ if @walker.error.nil?
28
+ [500, {}, 'Server Error']
29
+ else
30
+ @walker.error.odata_get(@request)
31
+ end
32
+ end
33
+
34
+ def odata_delete
35
+ if @walker.status == :end
36
+ @walker.end_context.odata_delete(@request)
37
+ else
38
+ odata_error
39
+ end
40
+ end
41
+
42
+ def odata_patch
43
+ if @walker.status == :end
44
+ @walker.end_context.odata_patch(@request)
45
+ else
46
+ odata_error
47
+ end
48
+ end
49
+
50
+ def odata_get
51
+ if @walker.status == :end
52
+ @walker.end_context.odata_get(@request)
53
+ else
54
+ odata_error
55
+ end
56
+ end
57
+
58
+ def odata_post
59
+ if @walker.status == :end
60
+ @walker.end_context.odata_post(@request)
61
+ else
62
+ odata_error
63
+ end
64
+ end
65
+
66
+ def odata_head
67
+ [200, {}, '']
68
+ end
69
+ end
70
+
71
+ # the main Rack server app. Source: the Rack docu/examples and partly
72
+ # inspired from Sinatra
73
+ class ServerApp
74
+ METHODS_REGEXP = Regexp.new('HEAD|OPTIONS|GET|POST|PATCH|PUT|DELETE')
75
+ include MethodHandlers
76
+ def before
77
+ headers 'Cache-Control' => 'no-cache'
78
+ headers 'Expires' => '-1'
79
+ headers 'Pragma' => 'no-cache'
80
+
81
+ @request.service_base = self.class.get_service_base
82
+
83
+ neg_error = @request.negotiate_service_version
84
+
85
+ raise Error if neg_error
86
+
87
+ return false unless @request.service
88
+
89
+ headers 'DataServiceVersion' => @request.service.data_service_version
90
+ end
91
+
92
+ # dispatch for all methods requiring parsing of the path
93
+ # with walker (ie. allmost all excepted HEAD)
94
+ def dispatch_with_walker
95
+ @walker = @request.create_odata_walker
96
+ case @request.request_method
97
+ when 'GET'
98
+ odata_get
99
+ when 'POST'
100
+ odata_post
101
+ when 'DELETE'
102
+ odata_delete
103
+ when 'OPTIONS'
104
+ odata_options
105
+ when 'PATCH', 'PUT'
106
+ odata_patch
107
+ else
108
+ raise Error
109
+ end
110
+ end
111
+
112
+ def dispatch
113
+ req_ret = if @request.request_method !~ METHODS_REGEXP
114
+ [404, {}, ['Did you get lost?']]
115
+ elsif @request.request_method == 'HEAD'
116
+ odata_head
117
+ else
118
+ dispatch_with_walker
119
+ end
120
+ @response.status, rsph, @response.body = req_ret
121
+ headers rsph
122
+ end
123
+
124
+ def call(env)
125
+ @request = OData::Request.new(env)
126
+ @response = OData::Response.new
127
+
128
+ before
129
+
130
+ dispatch
131
+
132
+ @response.finish
133
+ end
134
+
135
+ # Set multiple response headers with Hash.
136
+ def headers(hash = nil)
137
+ @response.headers.merge! hash if hash
138
+ @response.headers
139
+ end
140
+
141
+ def self.enable_batch
142
+ @service_base.enable_batch
143
+ end
144
+
145
+ def self.get_service_base
146
+ @service_base
147
+ end
148
+
149
+ def self.set_servicebase(sbase, p_prefix = '')
150
+ @service_base = sbase
151
+ @service_base.path_prefix p_prefix
152
+ @service_base.enable_v1_service
153
+ @service_base.enable_v2_service
154
+ end
155
+
156
+ def self.publish_service(&block)
157
+ sbase = OData::ServiceBase.new
158
+ sbase.instance_eval(&block) if block_given?
159
+ sbase.finalize_publishing
160
+ set_servicebase(sbase)
161
+ end
162
+ end
163
+ end
data/lib/request.rb ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rack'
4
+
5
+ module OData
6
+ # monkey patch deactivate Rack/multipart because it does not work on simple
7
+ # OData $batch requests when the content-length
8
+ # is not passed
9
+ class Request < Rack::Request
10
+ HEADER_PARAM = /\s*[\w.]+=(?:[\w.]+|"(?:[^"\\]|\\.)*")?\s*/.freeze
11
+ # HEADER_VALUE_WITH_PARAMS = /(?:(?:\w+|\*)\/(?:\w+(?:\.|\-|\+)?|\*)*)
12
+ # \s*(?:;#{HEADER_PARAM})*/
13
+ HEADER_VAL_RAW = '(?:\w+|\*)\/(?:\w+(?:\.|\-|\+)?|\*)*'.freeze
14
+ HEADER_VAL_WITH_PAR = /(?:#{HEADER_VAL_RAW})\s*(?:;#{HEADER_PARAM})*/.freeze
15
+
16
+ # borowed from Sinatra
17
+ class AcceptEntry
18
+ attr_accessor :params
19
+ attr_reader :entry
20
+ def initialize(entry)
21
+ params = entry.scan(HEADER_PARAM).map! do |s|
22
+ key, value = s.strip.split('=', 2)
23
+ value = value[1..-2].gsub(/\\(.)/, '\1') if value.start_with?('"')
24
+ [key, value]
25
+ end
26
+
27
+ @entry = entry
28
+ @type = entry[/[^;]+/].delete(' ')
29
+ @params = Hash[params]
30
+ @q = @params.delete('q') { 1.0 }.to_f
31
+ end
32
+
33
+ def method_missing(*args, &block)
34
+ to_str.send(*args, &block)
35
+ end
36
+
37
+ def <=>(other)
38
+ other.priority <=> priority
39
+ end
40
+
41
+ def priority
42
+ # We sort in descending order; better matches should be higher.
43
+ [@q, -@type.count('*'), @params.size]
44
+ end
45
+
46
+ def respond_to?(*args)
47
+ super || to_str.respond_to?(*args)
48
+ end
49
+
50
+ def to_s(full = false)
51
+ full ? entry : to_str
52
+ end
53
+
54
+ def to_str
55
+ @type
56
+ end
57
+ end
58
+ ## original coding:
59
+ # # The set of media-types. Requests that do not indicate
60
+ # # one of the media types presents in this list will not be eligible
61
+ # # for param parsing like soap attachments or generic multiparts
62
+ # PARSEABLE_DATA_MEDIA_TYPES = [
63
+ # 'multipart/related',
64
+ # 'multipart/mixed'
65
+ # ]
66
+ # # Determine whether the request body contains data by checking
67
+ # # the request media_type against registered parse-data media-types
68
+ # def parseable_data?
69
+ # PARSEABLE_DATA_MEDIA_TYPES.include?(media_type)
70
+ # end
71
+ def parseable_data?
72
+ false
73
+ end
74
+ # OData extension
75
+ attr_accessor :service_base
76
+ attr_accessor :service
77
+ attr_accessor :walker
78
+
79
+ def create_odata_walker
80
+ @walker = Walker.new(@service, path_info)
81
+ end
82
+
83
+ def accept
84
+ @env['safrano.accept'] ||= begin
85
+ if @env.include?('HTTP_ACCEPT') && (@env['HTTP_ACCEPT'].to_s != '')
86
+ @env['HTTP_ACCEPT'].to_s.scan(HEADER_VAL_WITH_PAR)
87
+ .map! { |e| AcceptEntry.new(e) }.sort
88
+ else
89
+ [AcceptEntry.new('*/*')]
90
+ end
91
+ end
92
+ end
93
+
94
+ def uribase
95
+ return @uribase if @uribase
96
+
97
+ @uribase =
98
+ "#{env['rack.url_scheme']}://#{env['HTTP_HOST']}#{service.xpath_prefix}"
99
+ end
100
+
101
+ def accept?(type)
102
+ preferred_type(type).to_s.include?(type)
103
+ end
104
+
105
+ def preferred_type(*types)
106
+ accepts = accept # just evaluate once
107
+ return accepts.first if types.empty?
108
+
109
+ types.flatten!
110
+ return types.first if accepts.empty?
111
+
112
+ accepts.detect do |pattern|
113
+ type = types.detect { |t| File.fnmatch(pattern, t) }
114
+ return type if type
115
+ end
116
+ end
117
+
118
+ def negotiate_service_version
119
+ maxv = if (rqv = env['HTTP_MAXDATASERVICEVERSION'])
120
+ OData::ServiceBase.parse_data_service_version(rqv)
121
+ else
122
+ OData::MAX_DATASERVICE_VERSION
123
+ end
124
+ return OData::BadRequestError if maxv.nil?
125
+ # client request an too old version --> 501
126
+ return OData::NotImplementedError if maxv < OData::MIN_DATASERVICE_VERSION
127
+
128
+ minv = if (rqv = env['HTTP_MINDATASERVICEVERSION'])
129
+ OData::ServiceBase.parse_data_service_version(rqv)
130
+ else
131
+ OData::MIN_DATASERVICE_VERSION
132
+ end
133
+ return OData::BadRequestError if minv.nil?
134
+ # client request an too new version --> 501
135
+ return OData::NotImplementedError if minv > OData::MAX_DATASERVICE_VERSION
136
+ return OData::BadRequestError if minv > maxv
137
+
138
+ v = if (rqv = env['HTTP_DATASERVICEVERSION'])
139
+ OData::ServiceBase.parse_data_service_version(rqv)
140
+ else
141
+ OData::MAX_DATASERVICE_VERSION
142
+ end
143
+
144
+ return OData::BadRequestError if v.nil?
145
+ return OData::NotImplementedError if v > OData::MAX_DATASERVICE_VERSION
146
+ return OData::NotImplementedError if v < OData::MIN_DATASERVICE_VERSION
147
+
148
+ @service = nil
149
+ @service = case maxv
150
+ when '1'
151
+ @service_base.v1
152
+ when '2', '3', '4'
153
+ @service_base.v2
154
+ end
155
+ nil
156
+ end
157
+ end
158
+ end
data/lib/response.rb ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env ruby
2
+ require 'rack'
3
+
4
+ # monkey patch deactivate Rack/multipart because it does not work on simple
5
+ # OData $batch requests when the content-length is not passed
6
+ module OData
7
+ # borrowed fro Sinatra
8
+ # The response object. See Rack::Response and Rack::Response::Helpers for
9
+ # more info:
10
+ # http://rubydoc.info/github/rack/rack/master/Rack/Response
11
+ # http://rubydoc.info/github/rack/rack/master/Rack/Response/Helpers
12
+ class Response < ::Rack::Response
13
+ DROP_BODY_RESPONSES = [204, 205, 304].freeze
14
+ def initialize(*)
15
+ super
16
+ headers['Content-Type'] ||= 'text/html'
17
+ end
18
+
19
+ def body=(value)
20
+ value = value.body while ::Rack::Response === value
21
+ @body = String === value ? [value.to_str] : value
22
+ end
23
+
24
+ def each
25
+ block_given? ? super : enum_for(:each)
26
+ end
27
+
28
+ def finish
29
+ result = body
30
+
31
+ if drop_content_info?
32
+ headers.delete 'Content-Length'
33
+ headers.delete 'Content-Type'
34
+ end
35
+
36
+ if drop_body?
37
+ close
38
+ result = []
39
+ end
40
+
41
+ if calculate_content_length?
42
+ # if some other code has already set Content-Length, don't muck with it
43
+ # currently, this would be the static file-handler
44
+ headers['Content-Length'] = calculated_content_length.to_s
45
+ end
46
+
47
+ [status.to_i, headers, result]
48
+ end
49
+
50
+ private
51
+
52
+ def calculate_content_length?
53
+ headers['Content-Type'] && !headers['Content-Length'] && (Array === body)
54
+ end
55
+
56
+ def calculated_content_length
57
+ body.inject(0) { |l, p| l + p.bytesize }
58
+ end
59
+
60
+ def drop_content_info?
61
+ (status.to_i / 100 == 1) || drop_body?
62
+ end
63
+
64
+ def drop_body?
65
+ DROP_BODY_RESPONSES.include?(status.to_i)
66
+ end
67
+ end
68
+ end
data/lib/safrano.rb ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'json'
4
+ require 'rexml/document'
5
+ require 'safrano_core.rb'
6
+ require 'odata/entity.rb'
7
+ require 'odata/collection.rb'
8
+ require 'service.rb'
9
+ require 'odata/walker.rb'
10
+ require 'sequel'
11
+ require 'rack_app'
12
+ require 'odata_rack_builder'
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Type mapping DB --> Edm
4
+ module OData
5
+ # TypeMap = {"INTEGER" => "Edm.Int32" , "TEXT" => "Edm.String",
6
+ # "STRING" => "Edm.String"}
7
+ # Todo: complete mapping... this is just for the most common ones
8
+
9
+ # TODO: use Sequel GENERIC_TYPES: -->
10
+ # Constants
11
+ # GENERIC_TYPES = %w'String Integer Float Numeric BigDecimal Date DateTime
12
+ # Time File TrueClass FalseClass'.freeze
13
+ # Classes specifying generic types that Sequel will convert to
14
+ # database-specific types.
15
+ def self.get_edm_type(db_type:)
16
+ case db_type
17
+ when 'INTEGER'
18
+ 'Edm.Int32'
19
+ when 'TEXT', 'STRING'
20
+ 'Edm.String'
21
+ else
22
+ 'Edm.String' if /\ACHAR\s*\(\d+\)\z/ =~ db_type
23
+ end
24
+ end
25
+ end
26
+
27
+ module REXML
28
+ # some small extensions
29
+ class Document
30
+ def to_pretty_xml
31
+ formatter = REXML::Formatters::Pretty.new(2)
32
+ formatter.compact = true
33
+ strio = ''
34
+ formatter.write(root, strio)
35
+ strio
36
+ end
37
+ end
38
+ end
39
+
40
+ # Core
41
+ module Safrano
42
+ # represents a state transition when navigating/parsing the url path
43
+ # from left to right
44
+ class Transition < Regexp
45
+ attr_accessor :trans
46
+ attr_accessor :match_result
47
+ attr_accessor :rgx
48
+ def initialize(arg, trans: nil)
49
+ @rgx = if arg.respond_to? :each_char
50
+ Regexp.new(arg)
51
+ else
52
+ arg
53
+ end
54
+ @trans = trans
55
+ end
56
+
57
+ def do_match(str)
58
+ @match_result = @rgx.match(str)
59
+ end
60
+
61
+ # this assumes some implicit rules in the way the regexps are built
62
+ def path_remain
63
+ @match_result[2] if @match_result && @match_result[2]
64
+ end
65
+
66
+ def path_done
67
+ if @match_result
68
+ @match_result[1] || ''
69
+ else
70
+ ''
71
+ end
72
+ end
73
+
74
+ def do_transition(ctx)
75
+ ctx.method(@trans).call(@match_result)
76
+ end
77
+ end
78
+
79
+ TransitionEnd = Transition.new('\A(\/?)\z', trans: 'transition_end')
80
+ TransitionMetadata = Transition.new('\A(\/\$metadata)(.*)',
81
+ trans: 'transition_metadata')
82
+ TransitionBatch = Transition.new('\A(\/\$batch)(.*)',
83
+ trans: 'transition_batch')
84
+ TransitionCount = Transition.new('(\A\/\$count)(.*)\z',
85
+ trans: 'transition_count')
86
+ attr_accessor :allowed_transitions
87
+ end
88
+
89
+ # for $count and number type attributes
90
+ # class Fixnum deprecated as of ruby 2.4+
91
+ class Integer
92
+ def odata_get(_req)
93
+ [200, { 'Content-Type' => 'text/plain;charset=utf-8' }, to_s]
94
+ end
95
+ end
96
+
97
+ # for string type attributes
98
+ class String
99
+ def odata_get(_req)
100
+ [200, { 'Content-Type' => 'text/plain;charset=utf-8' }, to_s]
101
+ end
102
+ end
103
+
104
+ # Final attribute value....
105
+ class Object
106
+ def allowed_transitions
107
+ [Safrano::TransitionEnd]
108
+ end
109
+
110
+ def transition_end(_match_result)
111
+ [nil, :end]
112
+ end
113
+ end
data/lib/service.rb ADDED
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rexml/document'
4
+ require 'odata/relations.rb'
5
+ require 'odata/batch.rb'
6
+ require 'odata/error.rb'
7
+
8
+ module OData
9
+ # this module has all methods related to expand/defered output preparation
10
+ # and will be included in Service class
11
+ module ExpandHandler
12
+ def get_deferred_odata_h(entity:, attrib:, uribase:)
13
+ { '__deferred' => { 'uri' => "#{entity.uri(uribase)}/#{attrib}" } }
14
+ end
15
+
16
+ def handle_entity_expand_one(entity:, exp_one:, nav_values_h:, nav_coll_h:,
17
+ uribase:)
18
+ if exp_one.include?('/')
19
+ m = %r{\A(\w+)\/?(.*)\z}.match(exp_one)
20
+ cur_exp = m[1].strip
21
+ rest_exp = m[2]
22
+ # TODO: check errorhandling
23
+ raise OData::ServerError if cur_exp.nil?
24
+
25
+ k = cur_exp.to_sym
26
+ else
27
+ k = exp_one.strip.to_sym
28
+ rest_exp = nil
29
+ end
30
+
31
+ if (enval = entity.nav_values[k])
32
+ nav_values_h[k.to_s] = get_entity_odata_h(entity: enval,
33
+ expand: rest_exp,
34
+ uribase: uribase)
35
+ elsif (encoll = entity.nav_coll[k])
36
+ # nav attributes that are a collection (x..n)
37
+ nav_coll_h[k.to_s] = encoll.map do |xe|
38
+ if xe
39
+ get_entity_odata_h(entity: xe, expand: rest_exp,
40
+ uribase: uribase)
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ def handle_entity_expand(entity:, expand:, nav_values_h:,
47
+ nav_coll_h:, uribase:)
48
+ expand.strip!
49
+ explist = expand.split(',')
50
+ # handle multiple expands
51
+ explist.each do |exp|
52
+ handle_entity_expand_one(entity: entity, exp_one: exp,
53
+ nav_values_h: nav_values_h,
54
+ nav_coll_h: nav_coll_h,
55
+ uribase: uribase)
56
+ end
57
+ end
58
+
59
+ def handle_entity_deferred_attribs(entity:, nav_values_h:,
60
+ nav_coll_h:, uribase:)
61
+ entity.nav_values.each_key do |ksy|
62
+ ks = ksy.to_s
63
+ next if nav_values_h.key?(ks)
64
+
65
+ nav_values_h[ks] = get_deferred_odata_h(entity: entity,
66
+ attrib: ks, uribase: uribase)
67
+ end
68
+ entity.nav_coll.each_key do |ksy|
69
+ ks = ksy.to_s
70
+ next if nav_coll_h.key?(ks)
71
+
72
+ nav_coll_h[ks] = get_deferred_odata_h(entity: entity, attrib: ks,
73
+ uribase: uribase)
74
+ end
75
+ end
76
+
77
+ def get_entity_odata_h(entity:, expand: nil, uribase:)
78
+ hres = {}
79
+ hres['__metadata'] = entity.metadata_h(uribase: uribase)
80
+ hres.merge!(entity.values)
81
+
82
+ nav_values_h = {}
83
+ nav_coll_h = {}
84
+
85
+ # handle expanded nav attributes
86
+ unless expand.nil?
87
+ handle_entity_expand(entity: entity, expand: expand,
88
+ nav_values_h: nav_values_h,
89
+ nav_coll_h: nav_coll_h,
90
+ uribase: uribase)
91
+ end
92
+
93
+ # handle not expanded (deferred) nav attributes
94
+ # TODO: better design/perf
95
+ handle_entity_deferred_attribs(entity: entity,
96
+ nav_values_h: nav_values_h,
97
+ nav_coll_h: nav_coll_h,
98
+ uribase: uribase)
99
+ # merge ...
100
+ hres.merge!(nav_values_h)
101
+ hres.merge!(nav_coll_h)
102
+
103
+ hres
104
+ end
105
+ end
106
+ end
107
+
108
+ module OData
109
+ # xml namespace constants needed for the output of XML service and
110
+ # and metadata
111
+ module XMLNS
112
+ MSFT_ADO = 'http://schemas.microsoft.com/ado'.freeze
113
+ MSFT_ADO_2009_EDM = "#{MSFT_ADO}/2009/11/edm".freeze
114
+ MSFT_ADO_2007_EDMX = "#{MSFT_ADO}/2007/06/edmx".freeze
115
+ MSFT_ADO_2007_META = MSFT_ADO + \
116
+ '/2007/08/dataservices/metadata'.freeze
117
+
118
+ W3_2005_ATOM = 'http://www.w3.org/2005/Atom'.freeze
119
+ W3_2007_APP = 'http://www.w3.org/2007/app'.freeze
120
+ end
121
+ end
122
+
123
+ # Link to Model
124
+ module OData
125
+ MAX_DATASERVICE_VERSION = '2'.freeze
126
+ MIN_DATASERVICE_VERSION = '1'.freeze
127
+ include XMLNS
128
+ # Base class for service. Subclass will be for V1, V2 etc...
129
+ class ServiceBase
130
+ XML_PREAMBLE = '<?xml version="1.0" encoding="utf-8" standalone="yes"?>' + \
131
+ "\r\n".freeze
132
+
133
+ # This is just a hash of entity Set Names to the corresponding Class
134
+ # Example
135
+ # Book < Sequel::Model(:book)
136
+ # @entity_set_name = 'books'
137
+ # end
138
+ # ---> @cmap ends up as {'books' => Book }
139
+ attr_reader :cmap
140
+
141
+ # this is just the *sorted* list of the entity classes
142
+ # (ie... @cmap.values.sorted)
143
+ attr_accessor :collections
144
+
145
+ attr_accessor :xtitle
146
+ attr_accessor :xname
147
+ attr_accessor :xnamespace
148
+ attr_accessor :xpath_prefix
149
+ # attr_accessor :xuribase
150
+ attr_accessor :meta
151
+ attr_accessor :batch_handler
152
+ attr_accessor :relman
153
+ # Instance attributes for specialized Version specific Instances
154
+ attr_accessor :v1
155
+ attr_accessor :v2
156
+
157
+ # TODO: more elegant design
158
+ attr_reader :data_service_version
159
+
160
+ def initialize(&block)
161
+ # Warning: if you add attributes here, you shall need add them
162
+ # in copy_attribs_to as well
163
+ # because of the version subclasses that dont use "super" initialise
164
+ # (todo: why not??)
165
+ @meta = ServiceMeta.new(self)
166
+ @batch_handler = OData::Batch::DisabledHandler.new
167
+ @relman = OData::RelationManager.new
168
+ @cmap = {}
169
+ instance_eval(&block) if block_given?
170
+ end
171
+
172
+ DATASERVICEVERSION_RGX = /\A([1234])(?:\.0);*\w*\z/.freeze
173
+ # input is the DataServiceVersion request header string, eg.
174
+ # '2.0;blabla' ---> Version -> 2
175
+ def self.parse_data_service_version(inp)
176
+ m = DATASERVICEVERSION_RGX.match(inp)
177
+ m[1] if m
178
+ end
179
+
180
+ def enable_batch
181
+ @batch_handler = OData::Batch::EnabledHandler.new
182
+ @v1.batch_handler = @batch_handler
183
+ @v2.batch_handler = @batch_handler
184
+ end
185
+
186
+ def enable_v1_service
187
+ @v1 = OData::ServiceV1.new
188
+ copy_attribs_to @v1
189
+ end
190
+
191
+ def enable_v2_service
192
+ @v2 = OData::ServiceV2.new
193
+ copy_attribs_to @v2
194
+ end
195
+
196
+ # public API
197
+ def name(nam)
198
+ @xname = nam
199
+ end
200
+
201
+ def namespace(namsp)
202
+ @xnamespace = namsp
203
+ end
204
+
205
+ def title(tit)
206
+ @xtitle = tit
207
+ end
208
+
209
+ def path_prefix(path_pr)
210
+ @xpath_prefix = path_pr.sub(%r{/\z}, '')
211
+ end
212
+ # end public API
213
+
214
+ def copy_attribs_to(other)
215
+ other.cmap = @cmap
216
+ other.collections = @collections
217
+ other.xtitle = @xtitle
218
+ other.xname = @xname
219
+ other.xnamespace = @xnamespace
220
+ # other.xuribase = @xuribase
221
+ other.xpath_prefix = @xpath_prefix
222
+ other.meta = ServiceMeta.new(other) # hum ... #todo: versions as well ?
223
+ other.relman = @relman
224
+ other.batch_handler = @batch_handler
225
+ other
226
+ end
227
+
228
+ def register_model(modelklass, entity_set_name = nil)
229
+ # check that the provided klass is a Sequel Model
230
+ unless modelklass.is_a? Sequel::Model::ClassMethods
231
+ raise OData::API::ModelNameError, modelklass
232
+ end
233
+
234
+ if modelklass.primary_key.is_a?(Array)
235
+ modelklass.extend OData::EntityClassMultiPK
236
+ modelklass.include OData::EntityMultiPK
237
+ else
238
+ modelklass.extend OData::EntityClassSinglePK
239
+ modelklass.include OData::EntitySinglePK
240
+ end
241
+
242
+ modelklass.prepare_pk
243
+ modelklass.prepare_fields
244
+ esname = (entity_set_name || modelklass).to_s.freeze
245
+ modelklass.instance_eval { @entity_set_name = esname }
246
+ @cmap[esname] = modelklass
247
+ set_collections_sorted(@cmap.values)
248
+ end
249
+
250
+ def publish_model(modelklass, entity_set_name = nil, &block)
251
+ register_model(modelklass, entity_set_name)
252
+ # we need to execute the passed block in a deferred step
253
+ # after all models have been registered (due to rel. dependancies)
254
+ # modelklass.instance_eval(&block) if block_given?
255
+ modelklass.deferred_iblock = block if block_given?
256
+ end
257
+
258
+ def cmap=(imap)
259
+ @cmap = imap
260
+ set_collections_sorted(@cmap.values)
261
+ end
262
+
263
+ # take care of sorting required to match longest first while parsing
264
+ # with base_url_regexp
265
+ # example: CrewMember must be matched before Crew otherwise we get error
266
+ def set_collections_sorted(coll_data)
267
+ @collections = coll_data
268
+ if @collections
269
+ @collections.sort_by! { |klass| klass.entity_set_name.size }.reverse!
270
+ end
271
+ @collections
272
+ end
273
+
274
+ # to be called at end of publishing block to ensure we get the right names
275
+ # and additionally build the list of valid attribute path's used
276
+ # for validation of $orderby or $filter params
277
+ def finalize_publishing
278
+ # build the cmap
279
+ @cmap = {}
280
+ @collections.each do |klass|
281
+ @cmap[klass.entity_set_name] = klass
282
+ end
283
+
284
+ # now that we know all model klasses we can handle relationships
285
+ execute_deferred_iblocks
286
+
287
+ # and finally build the path list
288
+ @collections.each(&:build_attribute_path_list)
289
+ end
290
+
291
+ def execute_deferred_iblocks
292
+ @collections.each do |k|
293
+ k.instance_eval(&k.deferred_iblock) if k.deferred_iblock
294
+ end
295
+ end
296
+
297
+ ## Warning: base_url_regexp depends on '@collections', and this needs to be
298
+ ## evaluated after '@collections' is filled !
299
+ # A regexp matching all allowed base entities (eg product|categories )
300
+ def base_url_regexp
301
+ @collections.map(&:entity_set_name).join('|')
302
+ end
303
+
304
+ include Safrano
305
+ include ExpandHandler
306
+
307
+ def service
308
+ hres = {}
309
+ hres['d'] = { 'EntitySets' => @collections.map(&:type_name) }
310
+ hres
311
+ end
312
+
313
+ def service_xml(req)
314
+ doc = REXML::Document.new
315
+ # separator required ? ?
316
+ root = doc.add_element('service', 'xml:base' => req.uribase)
317
+
318
+ root.add_namespace('xmlns:atom', XMLNS::W3_2005_ATOM)
319
+ root.add_namespace('xmlns:app', XMLNS::W3_2007_APP)
320
+ # this generates the main xmlns attribute
321
+ root.add_namespace(XMLNS::W3_2007_APP)
322
+ wp = root.add_element 'workspace'
323
+
324
+ title = wp.add_element('atom:title')
325
+ title.text = @xtitle
326
+
327
+ @collections.each do |klass|
328
+ col = wp.add_element('collection', 'href' => klass.entity_set_name)
329
+ ct = col.add_element('atom:title')
330
+ ct.text = klass.type_name
331
+ end
332
+
333
+ XML_PREAMBLE + doc.to_pretty_xml
334
+ end
335
+
336
+ def metadata_xml(_req)
337
+ doc = REXML::Document.new
338
+ doc.add_element('edmx:Edmx', 'Version' => '1.0')
339
+ doc.root.add_namespace('xmlns:edmx', XMLNS::MSFT_ADO_2007_EDMX)
340
+ serv = doc.root.add_element('edmx:DataServices',
341
+ 'm:DataServiceVersion' => '1.0')
342
+ # 'm:DataServiceVersion' => "#{self.dataServiceVersion}" )
343
+ # DataServiceVersion: This attribute MUST be in the data service
344
+ # metadata namespace
345
+ # (http://schemas.microsoft.com/ado/2007/08/dataservices) and SHOULD
346
+ # be present on an
347
+ # edmx:DataServices element [MC-EDMX] to indicate the version of the
348
+ # data service CSDL
349
+ # annotations (attributes in the data service metadata namespace) that
350
+ # are used by the document.
351
+ # Consumers of a data-service metadata endpoint ought to first read this
352
+ # attribute value to determine if
353
+ # they can safely interpret all constructs within the document. The
354
+ # value of this attribute MUST be 1.0
355
+ # unless a "FC_KeepInContent" customizable feed annotation
356
+ # (section 2.2.3.7.2.1) with a value equal to
357
+ # false is present in the CSDL document within the edmx:DataServices
358
+ # node. In this case, the
359
+ # attribute value MUST be 2.0 or greater.
360
+ # In the absence of DataServiceVersion, consumers of the CSDL document
361
+ # can assume the highest DataServiceVersion they can handle.
362
+ serv.add_namespace('xmlns:m', XMLNS::MSFT_ADO_2007_META)
363
+
364
+ schema = serv.add_element('Schema',
365
+ 'Namespace' => @xnamespace,
366
+ 'xmlns' => XMLNS::MSFT_ADO_2009_EDM)
367
+ @collections.each do |klass|
368
+ # 1. all EntityType
369
+ enty = schema.add_element('EntityType', 'Name' => klass.to_s)
370
+ # with their properties
371
+ klass.db_schema.each do |pnam, prop|
372
+ if prop[:primary_key] == true
373
+ enty.add_element('Key').add_element('PropertyRef',
374
+ 'Name' => pnam.to_s)
375
+ end
376
+ # attrs = {'Name'=>pnam.to_s,
377
+ # 'Type'=>OData::TypeMap[ prop[:db_type] ]}
378
+ attrs = { 'Name' => pnam.to_s,
379
+ 'Type' => OData.get_edm_type(db_type: prop[:db_type]) }
380
+ attrs['Nullable'] = 'false' if prop[:allow_null] == false
381
+ enty.add_element('Property', attrs)
382
+ end
383
+ # and their Nav attributes == Sequel Model association
384
+ klass.associations.each do |assoc|
385
+ # associated objects need to be in the map...
386
+ next unless @cmap[assoc.to_s]
387
+
388
+ from = klass.type_name
389
+ to = @cmap[assoc.to_s].type_name
390
+
391
+ rel = @relman.get([from, to])
392
+
393
+ # use Sequel reflection to get multiplicity (will be used later
394
+ # in 2. Associations below)
395
+ reflect = klass.association_reflection(assoc)
396
+ case reflect[:type]
397
+ # TODO: use multiplicity 1 when needed instead of '0..1'
398
+ when :one_to_one
399
+ rel.set_multiplicity(from, '0..1')
400
+ rel.set_multiplicity(to, '0..1')
401
+ when :one_to_many
402
+ rel.set_multiplicity(from, '0..1')
403
+ rel.set_multiplicity(to, '*')
404
+ when :many_to_one
405
+ rel.set_multiplicity(from, '*')
406
+ rel.set_multiplicity(to, '0..1')
407
+ when :many_to_many
408
+ rel.set_multiplicity(from, '*')
409
+ rel.set_multiplicity(to, '*')
410
+ end
411
+ # <NavigationProperty Name="Supplier"
412
+ # Relationship="ODataDemo.Product_Supplier_Supplier_Products"
413
+ # FromRole="Product_Supplier" ToRole="Supplier_Products"/>
414
+
415
+ nattrs = { 'Name' => to,
416
+ 'Relationship' => "#{@xnamespace}.#{rel.name}",
417
+ 'FromRole' => from,
418
+ 'ToRole' => to }
419
+ enty.add_element('NavigationProperty', nattrs)
420
+ end
421
+ end
422
+ # 2. Associations
423
+ @relman.each_rel do |rel|
424
+ assoc = schema.add_element('Association', 'Name' => rel.name)
425
+
426
+ rel.each_endobj do |eo|
427
+ assoend = { 'Type' => "#{@xnamespace}.#{eo}",
428
+ 'Role' => eo,
429
+ 'Multiplicity' => rel.multiplicity[eo] }
430
+
431
+ assoc.add_element('End', assoend)
432
+ end
433
+ end
434
+
435
+ # 3. Enty container
436
+ ec = schema.add_element('EntityContainer',
437
+ 'Name' => @xname,
438
+ 'm:IsDefaultEntityContainer' => 'true')
439
+ @collections.each do |klass|
440
+ # 3.a Entity set's
441
+ ec.add_element('EntitySet',
442
+ 'Name' => klass.entity_set_name,
443
+ 'EntityType' => "#{@xnamespace}.#{klass.type_name}")
444
+ end
445
+ # 3.b Association set's
446
+ @relman.each_rel do |rel|
447
+ assoc = ec.add_element('AssociationSet',
448
+ 'Name' => rel.name,
449
+ 'Association' => "#{@xnamespace}.#{rel.name}")
450
+
451
+ rel.each_endobj do |eo|
452
+ clazz = Object.const_get(eo)
453
+ assoend = { 'EntitySet' => clazz.entity_set_name.to_s, 'Role' => eo }
454
+ assoc.add_element('End', assoend)
455
+ end
456
+ end
457
+ XML_PREAMBLE + doc.to_pretty_xml
458
+ end
459
+
460
+ # methods related to transitions to next state (cf. walker)
461
+ module Transitions
462
+ def allowed_transitions
463
+ @allowed_transitions = [
464
+ Safrano::TransitionEnd,
465
+ Safrano::TransitionMetadata,
466
+ Safrano::TransitionBatch,
467
+ Safrano::Transition.new(%r{\A/(#{base_url_regexp})(.*)},
468
+ trans: 'transition_collection')
469
+ ]
470
+ end
471
+
472
+ def transition_collection(match_result)
473
+ [@cmap[match_result[1]], :run] if match_result[1]
474
+ end
475
+
476
+ def transition_batch(_match_result)
477
+ [@batch_handler, :run]
478
+ end
479
+
480
+ def transition_metadata(_match_result)
481
+ [@meta, :run]
482
+ end
483
+
484
+ def transition_end(_match_result)
485
+ [nil, :end]
486
+ end
487
+ end
488
+
489
+ include Transitions
490
+
491
+ def odata_get(req)
492
+ if req.accept?('application/xml')
493
+ # app.headers 'Content-Type' => 'application/xml;charset=utf-8'
494
+ # Doc: 2.2.3.7.1 Service Document As per [RFC5023], AtomPub Service
495
+ # Documents MUST be
496
+ # identified with the "application/atomsvc+xml" media type (see
497
+ # [RFC5023] section 8).
498
+ [200, { 'Content-Type' => 'application/atomsvc+xml;charset=utf-8' },
499
+ service_xml(req)]
500
+ else
501
+ # TODO: other formats ?
502
+ # this is returned by http://services.odata.org/V2/OData/OData.svc
503
+ 415
504
+ end
505
+ end
506
+ end
507
+
508
+ # for OData V1
509
+ class ServiceV1 < ServiceBase
510
+ def initialize
511
+ @data_service_version = '1.0'
512
+ end
513
+
514
+ def get_coll_odata_h(array:, expand: nil, uribase:)
515
+ array.map do |w|
516
+ get_entity_odata_h(entity: w,
517
+ expand: expand,
518
+ uribase: uribase)
519
+ end
520
+ end
521
+
522
+ def get_emptycoll_odata_h
523
+ [{}]
524
+ end
525
+ end
526
+
527
+ # for OData V2
528
+ class ServiceV2 < ServiceBase
529
+ def initialize
530
+ @data_service_version = '2.0'
531
+ end
532
+
533
+ def get_coll_odata_h(array:, expand: nil, uribase:)
534
+ { 'results' =>
535
+ array.map do |w|
536
+ get_entity_odata_h(entity: w,
537
+ expand: expand,
538
+ uribase: uribase)
539
+ end }
540
+ end
541
+
542
+ def get_emptycoll_odata_h
543
+ { 'results' => [{}] }
544
+ end
545
+ end
546
+
547
+ # a virtual entity for the service metadata
548
+ class ServiceMeta
549
+ attr_accessor :service
550
+ def initialize(service)
551
+ @service = service
552
+ end
553
+
554
+ def allowed_transitions
555
+ @allowed_transitions = [Safrano::Transition.new('',
556
+ trans: 'transition_end')]
557
+ end
558
+
559
+ def transition_end(_match_result)
560
+ [nil, :end]
561
+ end
562
+
563
+ def odata_get(req)
564
+ if req.accept?('application/xml')
565
+ [200, { 'Content-Type' => 'application/xml;charset=utf-8' },
566
+ @service.metadata_xml(req)]
567
+ else # TODO: other formats
568
+ 415
569
+ end
570
+ end
571
+ end
572
+ end
573
+ # end of Module OData
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: safrano
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - D.M.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-04-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sequel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.15'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.15'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rack
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '12.3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '12.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.51'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.51'
69
+ description: Safrano is a small experimental OData server framework based on Ruby,
70
+ Rack and Sequel.
71
+ email: dm@0data.dev
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - lib/odata_rack_builder.rb
77
+ - lib/rack_app.rb
78
+ - lib/request.rb
79
+ - lib/response.rb
80
+ - lib/safrano.rb
81
+ - lib/safrano_core.rb
82
+ - lib/service.rb
83
+ homepage: https://gitlab.com/dm0da/safrano
84
+ licenses:
85
+ - MIT
86
+ metadata: {}
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.0.3
103
+ signing_key:
104
+ specification_version: 4
105
+ summary: Safrano
106
+ test_files: []