odata_duty 0.30.0
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 +7 -0
- data/README.md +312 -0
- data/lib/generators/odata_duty/entity_set/entity_set_generator.rb +56 -0
- data/lib/generators/odata_duty/entity_set/templates/entity_set.rb.erb +20 -0
- data/lib/generators/odata_duty/entity_set/templates/entity_set_spec.rb.erb +38 -0
- data/lib/generators/odata_duty/entity_set/templates/entity_type.rb.erb +6 -0
- data/lib/generators/odata_duty/entity_set/templates/entity_type_spec.rb.erb +20 -0
- data/lib/generators/odata_duty/entity_set/templates/odata_active_record_concern.rb.erb +109 -0
- data/lib/generators/odata_duty/entity_set/templates/resolver.rb.erb +39 -0
- data/lib/generators/odata_duty/entity_set/templates/resolver_spec.rb.erb +50 -0
- data/lib/generators/odata_duty/install/install_generator.rb +60 -0
- data/lib/generators/odata_duty/install/templates/controller.rb.tt +43 -0
- data/lib/generators/odata_duty/install/templates/schema.rb.tt +16 -0
- data/lib/metadata.xml.erb +116 -0
- data/lib/odata_duty/complex_type.rb +70 -0
- data/lib/odata_duty/context_wrapper.rb +26 -0
- data/lib/odata_duty/create_complex_type_hash_wrapper.rb +49 -0
- data/lib/odata_duty/dynamic_object_wrapper.rb.erb +79 -0
- data/lib/odata_duty/edms.rb +122 -0
- data/lib/odata_duty/edmx_schema.rb +19 -0
- data/lib/odata_duty/entity_type.rb +72 -0
- data/lib/odata_duty/enum_type.rb +71 -0
- data/lib/odata_duty/errors.rb +52 -0
- data/lib/odata_duty/executor.rb +277 -0
- data/lib/odata_duty/filter.rb +76 -0
- data/lib/odata_duty/filter_predicate.rb +12 -0
- data/lib/odata_duty/mapper_builder.rb +86 -0
- data/lib/odata_duty/mcp_input_schemas.rb +59 -0
- data/lib/odata_duty/mcp_server_builder.rb +107 -0
- data/lib/odata_duty/oas2/collection_get_path.rb +97 -0
- data/lib/odata_duty/oas2/collection_post_path.rb +63 -0
- data/lib/odata_duty/oas2/individual_delete_path.rb +42 -0
- data/lib/odata_duty/oas2/individual_get_path.rb +37 -0
- data/lib/odata_duty/oas2/individual_patch_path.rb +63 -0
- data/lib/odata_duty/oas2.rb +113 -0
- data/lib/odata_duty/parslet_search_expression.rb +163 -0
- data/lib/odata_duty/property/collection_prop.rb +25 -0
- data/lib/odata_duty/property/single_prop.rb +131 -0
- data/lib/odata_duty/property.rb +49 -0
- data/lib/odata_duty/railtie.rb +11 -0
- data/lib/odata_duty/schema_builder/complex_type.rb +34 -0
- data/lib/odata_duty/schema_builder/container.rb +16 -0
- data/lib/odata_duty/schema_builder/data_type.rb +18 -0
- data/lib/odata_duty/schema_builder/endpoint.rb +117 -0
- data/lib/odata_duty/schema_builder/entity_set.rb +67 -0
- data/lib/odata_duty/schema_builder/entity_type.rb +49 -0
- data/lib/odata_duty/schema_builder/enum_type.rb +32 -0
- data/lib/odata_duty/schema_builder.rb +130 -0
- data/lib/odata_duty/set_resolver.rb +51 -0
- data/lib/odata_duty.rb +311 -0
- metadata +168 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
class EnumMember
|
|
3
|
+
attr_reader :name
|
|
4
|
+
|
|
5
|
+
def initialize(name)
|
|
6
|
+
@name = name.to_str
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class EnumType
|
|
11
|
+
def self.members
|
|
12
|
+
@members ||= []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.member(name)
|
|
16
|
+
members << EnumMember.new(name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Metadata
|
|
20
|
+
attr_reader :enum_type
|
|
21
|
+
|
|
22
|
+
def initialize(enum_type)
|
|
23
|
+
@enum_type = enum_type
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def members
|
|
27
|
+
enum_type.members
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def scalar?
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def name
|
|
35
|
+
enum_type.to_s.split('::').last.sub(/EnumType\z/, '').sub(/Enum\z/, '')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def metadata_type
|
|
39
|
+
:enum
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def property_type
|
|
43
|
+
name
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.__metadata
|
|
48
|
+
Metadata.new(self)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :object
|
|
52
|
+
|
|
53
|
+
def initialize(object, _context)
|
|
54
|
+
@object = object
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.to_value(*)
|
|
58
|
+
new(*).__to_value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def __to_value
|
|
62
|
+
return object if __member_names.include?(object)
|
|
63
|
+
|
|
64
|
+
raise InvalidValue, "#{object} is not a valid member of #{__member_names}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def __member_names
|
|
68
|
+
@__member_names ||= self.class.members.map(&:name)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
class PropertyAlreadyDefinedError < ArgumentError; end
|
|
5
|
+
class InvalidNCNamesError < ArgumentError; end
|
|
6
|
+
class InitArgsMismatchError < ArgumentError; end
|
|
7
|
+
|
|
8
|
+
class RequestError < Error
|
|
9
|
+
attr_reader :code, :target
|
|
10
|
+
|
|
11
|
+
def initialize(message = 'Request Error', code: nil, target: nil)
|
|
12
|
+
super(message)
|
|
13
|
+
@code = code
|
|
14
|
+
@target = target
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def status
|
|
18
|
+
:internal_server_error
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class ServerError < RequestError; end
|
|
23
|
+
|
|
24
|
+
class NoImplementationError < ServerError
|
|
25
|
+
def status
|
|
26
|
+
:not_implemented
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class NotYetSupportedError < NoImplementationError; end
|
|
31
|
+
class InvalidValue < ServerError; end
|
|
32
|
+
|
|
33
|
+
class ClientError < RequestError
|
|
34
|
+
def status
|
|
35
|
+
:bad_request
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class ResourceNotFoundError < ClientError
|
|
40
|
+
def status
|
|
41
|
+
:not_found
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class UnknownPropertyError < ClientError; end
|
|
46
|
+
class UnknownCollectionError < ClientError; end
|
|
47
|
+
class InvalidFilterValue < ClientError; end
|
|
48
|
+
class InvalidPropertyReferenceValue < ClientError; end
|
|
49
|
+
class InvalidType < ClientError; end
|
|
50
|
+
class NoSuchPropertyError < ClientError; end
|
|
51
|
+
class InvalidQueryOptionError < ClientError; end
|
|
52
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
require_relative 'context_wrapper'
|
|
2
|
+
require_relative 'parslet_search_expression'
|
|
3
|
+
|
|
4
|
+
module OdataDuty
|
|
5
|
+
class Executor # rubocop:disable Metrics/ClassLength
|
|
6
|
+
def self.execute(**)
|
|
7
|
+
new(**).execute
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.create(**)
|
|
11
|
+
new(**).create
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.update(**)
|
|
15
|
+
new(**).update
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.delete(**)
|
|
19
|
+
new(**).delete
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :schema, :url, :context, :query_options
|
|
23
|
+
|
|
24
|
+
def initialize(schema:, url:, context:, query_options:)
|
|
25
|
+
@schema = schema
|
|
26
|
+
@url = url
|
|
27
|
+
@context = context
|
|
28
|
+
@query_options = query_options
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def points = schema.endpoints
|
|
32
|
+
|
|
33
|
+
def execute
|
|
34
|
+
set_builder = prepare_builder(endpoint, wrapped_context, query_options)
|
|
35
|
+
select = query_options['$select']
|
|
36
|
+
props = selected(endpoint.entity_type, select) if select
|
|
37
|
+
apply_select(set_builder, props)
|
|
38
|
+
|
|
39
|
+
if url.include?('(')
|
|
40
|
+
individual(set_builder, endpoint, wrapped_context, props)
|
|
41
|
+
elsif url.include?('/$count')
|
|
42
|
+
apply_search(set_builder, query_options['$search'])
|
|
43
|
+
set_builder.count
|
|
44
|
+
else
|
|
45
|
+
collection(set_builder, endpoint, wrapped_context, query_options, props)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create
|
|
50
|
+
unless endpoint.supports_create?
|
|
51
|
+
raise NoImplementationError, "create not implemented for #{endpoint.url}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Oj.dump(endpoint
|
|
55
|
+
.create(context: wrapped_context)
|
|
56
|
+
.merge(
|
|
57
|
+
'@odata.context': wrapped_context.od_full_url('$metadata',
|
|
58
|
+
anchor: "#{endpoint.name}/$entity")
|
|
59
|
+
),
|
|
60
|
+
mode: :compat)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def update
|
|
64
|
+
unless endpoint.supports_update?
|
|
65
|
+
raise NoImplementationError, "update not implemented for #{endpoint.url}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
entity_id = extract_value_from_brackets(url)
|
|
69
|
+
Oj.dump(endpoint
|
|
70
|
+
.update(entity_id, context: wrapped_context)
|
|
71
|
+
.merge(
|
|
72
|
+
'@odata.context': wrapped_context.od_full_url('$metadata',
|
|
73
|
+
anchor: "#{endpoint.name}/$entity")
|
|
74
|
+
),
|
|
75
|
+
mode: :compat)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def delete
|
|
79
|
+
unless endpoint.supports_delete?
|
|
80
|
+
raise NoImplementationError, "delete not implemented for #{endpoint.url}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
entity_id = extract_value_from_brackets(url)
|
|
84
|
+
endpoint.delete(entity_id, context: wrapped_context)
|
|
85
|
+
Oj.dump({ '@odata.context': wrapped_context.od_full_url('$metadata') }, mode: :compat)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def prepare_builder(endpoint, context, query_options)
|
|
89
|
+
endpoint.new_entity_set(context: context).tap do |set_builder|
|
|
90
|
+
filter = query_options['$filter']
|
|
91
|
+
apply_filter(endpoint, set_builder, filter) if query_options.key?('$filter')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def wrapped_context
|
|
98
|
+
@wrapped_context ||= ContextWrapper.new(context, base_url: schema.base_url,
|
|
99
|
+
endpoint: endpoint,
|
|
100
|
+
query_options: query_options)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def endpoint
|
|
104
|
+
@endpoint ||= points
|
|
105
|
+
.find { |e| url.split('/$count').first.split('(').first == e.url }
|
|
106
|
+
.tap do |result|
|
|
107
|
+
raise UnknownPropertyError, "No endpoint #{url} found in #{urls}" unless result
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def urls = points.map(&:url)
|
|
112
|
+
|
|
113
|
+
def apply_filter(endpoint, set_builder, filter_string)
|
|
114
|
+
filters = Filter.parse(filter_string)
|
|
115
|
+
if Filter.or?(filter_string)
|
|
116
|
+
apply_or_filter(endpoint, set_builder, filters)
|
|
117
|
+
else
|
|
118
|
+
filters.each { |filter| apply_and_filter(endpoint, set_builder, filter) }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def apply_and_filter(endpoint, set_builder, filter)
|
|
123
|
+
value = filter_value(endpoint, set_builder, filter)
|
|
124
|
+
_filter(filter, set_builder, value)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def apply_or_filter(endpoint, set_builder, filters)
|
|
128
|
+
unless set_builder.respond_to?(:od_filter_or)
|
|
129
|
+
raise NoImplementationError, 'OR filtering not supported'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
predicates = filters.map do |filter|
|
|
133
|
+
FilterPredicate.new(property_name: filter.property_name, operation: filter.operation,
|
|
134
|
+
value: filter_value(endpoint, set_builder, filter))
|
|
135
|
+
end
|
|
136
|
+
set_builder.od_filter_or(predicates)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def filter_value(endpoint, set_builder, filter)
|
|
140
|
+
property = endpoint.entity_type.properties.find { |p| p.name == filter.property_name }
|
|
141
|
+
assert_filter_valid_for_property(filter, property)
|
|
142
|
+
property.filter_convert(filter.value, set_builder.context)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def assert_filter_valid_for_property(filter, property)
|
|
146
|
+
raise UnknownPropertyError, "No such property #{filter.property_name}" unless property
|
|
147
|
+
|
|
148
|
+
return unless property.collection?
|
|
149
|
+
|
|
150
|
+
raise InvalidQueryOptionError,
|
|
151
|
+
"Cannot apply '#{filter.operation}' to a collection property '#{property.name}'."
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def _filter(filter, set_builder, value)
|
|
155
|
+
specific_method = :"od_filter_#{filter.property_name}_#{filter.operation}"
|
|
156
|
+
generic_method = :"od_filter_#{filter.operation}"
|
|
157
|
+
if set_builder.respond_to?(specific_method)
|
|
158
|
+
set_builder.public_send(specific_method, value)
|
|
159
|
+
elsif set_builder.respond_to?(generic_method)
|
|
160
|
+
set_builder.public_send(generic_method, filter.property_name, value)
|
|
161
|
+
else
|
|
162
|
+
raise NoImplementationError, "#{filter.property_name} #{filter.operation} not supported"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def extract_value_from_brackets(url)
|
|
167
|
+
match_data = url.match(/.*\(([^)]+)\)/)
|
|
168
|
+
return nil unless match_data
|
|
169
|
+
|
|
170
|
+
value = match_data[1]
|
|
171
|
+
|
|
172
|
+
value.gsub(/\A"|"\z/, '').gsub(/\A'|'\z/, '')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def selected(entity_type, select_query)
|
|
176
|
+
valid_selected(select_query.split(',').map(&:strip)).map do |p|
|
|
177
|
+
entity_type.properties.find { |a| a.name.to_s == p }.tap do |prop|
|
|
178
|
+
raise UnknownPropertyError, "The property '#{p}' does not exist" unless prop
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
rescue UnknownPropertyError, InvalidQueryOptionError => e
|
|
182
|
+
e.backtrace.unshift entity_type._defined_at_
|
|
183
|
+
raise
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def valid_selected(selected)
|
|
187
|
+
selected.each do |p|
|
|
188
|
+
unless Property.valid_name?(p)
|
|
189
|
+
raise InvalidQueryOptionError, "The property '#{p}' is not valid"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def apply_remaining(query_options, set_builder)
|
|
195
|
+
query_options.except('$count', '$filter', '$select')
|
|
196
|
+
.select { |k, _| k.start_with?('$') }.each do |k, v|
|
|
197
|
+
__send__("apply_#{k[1..]}", set_builder, v)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def apply_select(set_builder, props)
|
|
202
|
+
return unless set_builder.respond_to?(:od_select)
|
|
203
|
+
|
|
204
|
+
selected = (props || endpoint.entity_type.properties).map(&:name)
|
|
205
|
+
selected += endpoint.entity_type.property_refs.map(&:name)
|
|
206
|
+
set_builder.od_select(selected.uniq)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def apply_top(set_builder, top)
|
|
210
|
+
if !set_builder.respond_to?(:od_top) && top
|
|
211
|
+
raise NoImplementationError, "$top not implemented for #{set_builder.class}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
set_builder.od_top(top) if top
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def apply_skip(set_builder, skip)
|
|
218
|
+
if !set_builder.respond_to?(:od_skip) && skip
|
|
219
|
+
raise NoImplementationError, "$skip not implemented for #{set_builder.class}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
set_builder.od_skip(skip) if skip
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def apply_skiptoken(set_builder, skiptoken)
|
|
226
|
+
if !set_builder.respond_to?(:od_skiptoken) && skiptoken
|
|
227
|
+
raise NoImplementationError, "$skiptoken not implemented for #{set_builder.class}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
set_builder.od_skiptoken(skiptoken) if skiptoken
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def apply_search(set_builder, search_string)
|
|
234
|
+
if !set_builder.respond_to?(:od_search) && search_string
|
|
235
|
+
raise NoImplementationError, "$search not implemented for #{set_builder.class}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
return unless search_string
|
|
239
|
+
|
|
240
|
+
search_expression = SearchExpression.parse(search_string)
|
|
241
|
+
set_builder.od_search(search_expression)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def add_next_link(data, set_builder, query_options, context)
|
|
245
|
+
return unless set_builder.od_next_link_skiptoken
|
|
246
|
+
|
|
247
|
+
next_query_options = query_options.merge(:$skiptoken => set_builder.od_next_link_skiptoken)
|
|
248
|
+
data[:'@odata.nextLink'] = context.od_full_url(url, **next_query_options)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
require 'oj'
|
|
252
|
+
|
|
253
|
+
def collection(set_builder, endpoint, context, query_options, props)
|
|
254
|
+
count = set_builder.count if query_options['$count'] == 'true'
|
|
255
|
+
apply_remaining(query_options, set_builder)
|
|
256
|
+
data = { '@odata.context' => context.od_full_url('$metadata', anchor: endpoint.name),
|
|
257
|
+
'value' => endpoint.collection(set_builder, context: context, selected: props) }
|
|
258
|
+
data['@odata.count'] = count if count
|
|
259
|
+
add_next_link(data, set_builder, query_options, context)
|
|
260
|
+
Oj.dump(data, mode: :compat)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def individual(set_builder, endpoint, context, props)
|
|
264
|
+
entity_id = extract_value_from_brackets(url)
|
|
265
|
+
apply_remaining(query_options, set_builder)
|
|
266
|
+
|
|
267
|
+
Oj.dump(
|
|
268
|
+
endpoint
|
|
269
|
+
.individual(set_builder, entity_id, context: context, selected: props)
|
|
270
|
+
.merge(
|
|
271
|
+
'@odata.context': context.od_full_url('$metadata', anchor: "#{endpoint.name}/$entity")
|
|
272
|
+
),
|
|
273
|
+
mode: :compat
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
class Filter
|
|
3
|
+
def self.parse(str)
|
|
4
|
+
validate(str)
|
|
5
|
+
separator = or?(str) ? ' or ' : ' and '
|
|
6
|
+
split_outside_quotes(str, separator).map { |s| new(s) }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.or?(str)
|
|
10
|
+
mask_quoted(str).include?(' or ')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.validate(str)
|
|
14
|
+
masked = mask_quoted(str)
|
|
15
|
+
if masked.include?(' and ') && masked.include?(' or ')
|
|
16
|
+
raise NotYetSupportedError, 'mixed AND/OR not supported'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
%w[add sub mul div mod].each do |operator|
|
|
20
|
+
if masked.include?(" #{operator} ")
|
|
21
|
+
raise NotYetSupportedError,
|
|
22
|
+
'filtering with arithmetic operators not supported'
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
return unless masked.include?('(')
|
|
26
|
+
|
|
27
|
+
raise NotYetSupportedError,
|
|
28
|
+
'filtering does not support functions or Grouping Operators'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Replace the contents of single-quoted literals with spaces so substring
|
|
32
|
+
# checks never match separators inside a value. Doubled quotes ('') escape.
|
|
33
|
+
def self.mask_quoted(str)
|
|
34
|
+
in_quote = false
|
|
35
|
+
str.each_char.map do |char|
|
|
36
|
+
if char == "'"
|
|
37
|
+
in_quote = !in_quote
|
|
38
|
+
char
|
|
39
|
+
else
|
|
40
|
+
in_quote ? ' ' : char
|
|
41
|
+
end
|
|
42
|
+
end.join
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.split_outside_quotes(str, separator)
|
|
46
|
+
remaining = str.dup
|
|
47
|
+
mask_quoted(str).split(separator).map do |masked_segment|
|
|
48
|
+
segment = remaining.slice!(0, masked_segment.length)
|
|
49
|
+
remaining.slice!(0, separator.length)
|
|
50
|
+
segment
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private_class_method :mask_quoted, :split_outside_quotes
|
|
55
|
+
|
|
56
|
+
def initialize(filter_string)
|
|
57
|
+
@property_name, @operation, @value = filter_string.split(nil, 3)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def value
|
|
61
|
+
@value.delete_prefix("'").delete_suffix("'")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def operation
|
|
65
|
+
@operation.to_sym
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def property_name
|
|
69
|
+
if @property_name.include?('/')
|
|
70
|
+
raise NotYetSupportedError, 'nested property filtering not supported yet'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@property_name.to_sym
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
|
|
3
|
+
module OdataDuty
|
|
4
|
+
class MapperBuilder
|
|
5
|
+
def self.build(complex_type, selected: nil, &)
|
|
6
|
+
mapper_class = build_class(complex_type, selected: selected)
|
|
7
|
+
mapper_class.new(complex_type, &)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.build_class(complex_type, selected:)
|
|
11
|
+
@dynamic_classes ||= {}
|
|
12
|
+
@dynamic_classes[complex_type] ||= {}
|
|
13
|
+
complex_classes = @dynamic_classes.fetch(complex_type)
|
|
14
|
+
selected_key = selected&.map(&:name)&.sort
|
|
15
|
+
complex_classes[selected_key] ||= eval_erb_class(complex_type, selected: selected)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
require 'erb'
|
|
19
|
+
ERB_TEMPLATE = ERB.new(File.read("#{__dir__}/dynamic_object_wrapper.rb.erb"), trim_mode: '<>')
|
|
20
|
+
ERB_TEMPLATE.location = ["#{__dir__}/dynamic_object_wrapper.rb.erb", 1]
|
|
21
|
+
ERB_TEMPLATE.freeze
|
|
22
|
+
|
|
23
|
+
def self.eval_erb_class(complex_type, selected:)
|
|
24
|
+
properties = selected || complex_type.properties
|
|
25
|
+
class_result = ERB_TEMPLATE.result(binding)
|
|
26
|
+
|
|
27
|
+
eval(class_result) # rubocop:disable Security/Eval
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_accessor :obj, :context
|
|
31
|
+
attr_reader :complex_type, :set_types, :calling_methods
|
|
32
|
+
|
|
33
|
+
# generated mappers fetch only the entries they need, so no filtering here
|
|
34
|
+
def initialize(complex_type, &block)
|
|
35
|
+
@complex_type = complex_type
|
|
36
|
+
@set_types = complex_type.properties.to_h { |cp| [cp.name, cp.set_type] }
|
|
37
|
+
@calling_methods = complex_type.properties.to_h { |cp| [cp.name, cp.calling_method] }
|
|
38
|
+
@block = block
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def wrapped_obj
|
|
42
|
+
@wrapped_obj ||= complex_type.new(obj, context)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def obj_to_hash(obj, context)
|
|
46
|
+
@wrapped_obj = nil
|
|
47
|
+
self.context = context
|
|
48
|
+
self.obj = obj
|
|
49
|
+
base_hash = to_h
|
|
50
|
+
@block.call(base_hash, obj)
|
|
51
|
+
base_hash
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def obj_to_base_hash(obj)
|
|
55
|
+
return nil if obj.nil?
|
|
56
|
+
|
|
57
|
+
@wrapped_obj = nil
|
|
58
|
+
self.obj = obj
|
|
59
|
+
to_h
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
VALID_BOOLEAN_VALUES = [true, false, nil].freeze
|
|
63
|
+
|
|
64
|
+
def confirm_boolean(name, value)
|
|
65
|
+
case value
|
|
66
|
+
when true, false, nil
|
|
67
|
+
value
|
|
68
|
+
else
|
|
69
|
+
raise InvalidValue, "Property #{name} must be a boolean and not #{value}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def not_nullable(name, value)
|
|
74
|
+
raise InvalidValue, "Property #{name} cannot be null" if value.nil?
|
|
75
|
+
|
|
76
|
+
value
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def confirm_one_of(name, value, valid_values)
|
|
80
|
+
return nil if value.nil?
|
|
81
|
+
return value if valid_values.include?(value)
|
|
82
|
+
|
|
83
|
+
raise InvalidValue, "Property #{name} must be one of #{valid_values} and not #{value}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
module McpInputSchemas
|
|
3
|
+
extend self
|
|
4
|
+
|
|
5
|
+
# Property names are used as-is (symbols) for keys/required and the root `type: object` is
|
|
6
|
+
# omitted; the MCP SDK normalizes the keys and supplies the root type default.
|
|
7
|
+
|
|
8
|
+
def count_input_schema(supports_search:)
|
|
9
|
+
properties = { '$filter' => { 'type' => 'string' } }
|
|
10
|
+
properties['$search'] = { 'type' => 'string' } if supports_search
|
|
11
|
+
{ 'properties' => properties, 'required' => [] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def list_input_schema(supports_search:)
|
|
15
|
+
properties = {
|
|
16
|
+
'$filter' => { 'type' => 'string', 'description' => 'OData $filter expression' },
|
|
17
|
+
'$select' => { 'type' => 'string',
|
|
18
|
+
'description' => 'Comma-separated properties to return' }
|
|
19
|
+
}
|
|
20
|
+
if supports_search
|
|
21
|
+
properties['$search'] = { 'type' => 'string',
|
|
22
|
+
'description' => 'Search expression (AND, OR, NOT)' }
|
|
23
|
+
end
|
|
24
|
+
properties['$top'] = { 'type' => 'integer', 'description' => 'Max records to return' }
|
|
25
|
+
properties['$skip'] = { 'type' => 'integer', 'description' => 'Records to skip' }
|
|
26
|
+
{ 'properties' => properties, 'required' => [] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_input_schema(entity_type)
|
|
30
|
+
writable = entity_type.properties.select(&:settable_on_create?)
|
|
31
|
+
properties = writable.to_h { |p| [p.name, p.to_oas2] }
|
|
32
|
+
required = writable.reject(&:nullable).map(&:name)
|
|
33
|
+
{ 'properties' => properties, 'required' => required }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update_input_schema(entity_type)
|
|
37
|
+
key = entity_type.property_refs.first
|
|
38
|
+
writable = entity_type.properties.select(&:settable_on_update?)
|
|
39
|
+
properties = { key.name => key.to_oas2 }
|
|
40
|
+
writable.each { |p| properties[p.name] = p.to_oas2 }
|
|
41
|
+
{ 'properties' => properties, 'required' => [key.name] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get_input_schema(entity_type)
|
|
45
|
+
key = entity_type.property_refs.first
|
|
46
|
+
properties = {
|
|
47
|
+
key.name => key.to_oas2,
|
|
48
|
+
'$select' => { 'type' => 'string',
|
|
49
|
+
'description' => 'Comma-separated properties to return' }
|
|
50
|
+
}
|
|
51
|
+
{ 'properties' => properties, 'required' => [key.name] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def delete_input_schema(entity_type)
|
|
55
|
+
key = entity_type.property_refs.first
|
|
56
|
+
{ 'properties' => { key.name => key.to_oas2 }, 'required' => [key.name] }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|