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,107 @@
|
|
|
1
|
+
require 'mcp'
|
|
2
|
+
require 'odata_duty/mcp_input_schemas'
|
|
3
|
+
|
|
4
|
+
module OdataDuty
|
|
5
|
+
module McpServerBuilder
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
def build(schema)
|
|
9
|
+
server = MCP::Server.new(
|
|
10
|
+
name: schema.title,
|
|
11
|
+
version: schema.version,
|
|
12
|
+
capabilities: { tools: {} }
|
|
13
|
+
)
|
|
14
|
+
schema.endpoints.each { |endpoint| register_endpoint_tools(server, schema, endpoint) }
|
|
15
|
+
server
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register_endpoint_tools(server, schema, endpoint)
|
|
19
|
+
register_collection_tools(server, schema, endpoint) if endpoint.supports_collection?
|
|
20
|
+
register_get_tool(server, schema, endpoint) if endpoint.supports_individual?
|
|
21
|
+
register_create_tool(server, schema, endpoint) if endpoint.supports_create?
|
|
22
|
+
register_update_tool(server, schema, endpoint) if endpoint.supports_update?
|
|
23
|
+
register_delete_tool(server, schema, endpoint) if endpoint.supports_delete?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def register_collection_tools(server, schema, endpoint)
|
|
27
|
+
register_list_tool(server, schema, endpoint)
|
|
28
|
+
register_count_tool(server, schema, endpoint) if endpoint.supports_count?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def register_update_tool(server, schema, endpoint)
|
|
32
|
+
register_key_tool(server, schema, endpoint, :update, 'Update an existing')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def register_delete_tool(server, schema, endpoint)
|
|
36
|
+
register_key_tool(server, schema, endpoint, :delete, 'Delete an existing')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def register_list_tool(server, schema, endpoint)
|
|
40
|
+
input_schema = McpInputSchemas.list_input_schema(supports_search: endpoint.supports_search?)
|
|
41
|
+
define_tool(server, schema, :execute,
|
|
42
|
+
url_for: ->(_args) { endpoint.url },
|
|
43
|
+
name: "list_#{endpoint.name}",
|
|
44
|
+
description: "List #{endpoint.name} records", input_schema: input_schema)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def register_count_tool(server, schema, endpoint)
|
|
48
|
+
input_schema = McpInputSchemas.count_input_schema(supports_search: endpoint.supports_search?)
|
|
49
|
+
define_tool(server, schema, :execute,
|
|
50
|
+
url_for: ->(_args) { "#{endpoint.url}/$count" },
|
|
51
|
+
name: "count_#{endpoint.name}",
|
|
52
|
+
description: "Count #{endpoint.name} records", input_schema: input_schema)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def register_create_tool(server, schema, endpoint)
|
|
56
|
+
define_tool(server, schema, :create,
|
|
57
|
+
url_for: ->(_args) { endpoint.url },
|
|
58
|
+
name: "create_#{endpoint.name}",
|
|
59
|
+
description: "Create a new #{endpoint.name} record",
|
|
60
|
+
input_schema: McpInputSchemas.create_input_schema(endpoint.entity_type))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def register_get_tool(server, schema, endpoint)
|
|
64
|
+
define_tool(server, schema, :execute,
|
|
65
|
+
url_for: keyed_url_for(endpoint),
|
|
66
|
+
name: "get_#{endpoint.name}",
|
|
67
|
+
description: "Get a single #{endpoint.name} record by ID",
|
|
68
|
+
input_schema: McpInputSchemas.get_input_schema(endpoint.entity_type))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def register_key_tool(server, schema, endpoint, action, verb)
|
|
72
|
+
input_schema = McpInputSchemas.public_send("#{action}_input_schema", endpoint.entity_type)
|
|
73
|
+
define_tool(server, schema, action,
|
|
74
|
+
url_for: keyed_url_for(endpoint),
|
|
75
|
+
name: "#{action}_#{endpoint.name}",
|
|
76
|
+
description: "#{verb} #{endpoint.name} record", input_schema: input_schema)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Builds the `<url>('<key>')` locator from the tool arguments. The dynamic `args[key]` lookup
|
|
80
|
+
# has no mutation-testable equivalent (the key is always present, enforced by the SDK's
|
|
81
|
+
# required-argument check), so this is the one MCP subject left on the .mutant.yml ignore list.
|
|
82
|
+
def keyed_url_for(endpoint)
|
|
83
|
+
key = endpoint.entity_type.property_refs.first.name
|
|
84
|
+
->(args) { "#{endpoint.url}('#{args[key]}')" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# On the .mutant.yml ignore list: `server_context[:context]` has only equivalent mutants
|
|
88
|
+
# (`[]` vs `fetch`/`dig`); the key is always present, so no public-API test distinguishes them.
|
|
89
|
+
def define_tool(server, schema, action, url_for:, **tool_args)
|
|
90
|
+
server.define_tool(**tool_args) do |server_context:, **args|
|
|
91
|
+
McpServerBuilder.run_tool(action, url: url_for.call(args), schema: schema,
|
|
92
|
+
context: server_context[:context],
|
|
93
|
+
query_options: args.transform_keys(&:to_s))
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# On the .mutant.yml ignore list: `e.message` has only an equivalent mutant (`e`), since an
|
|
98
|
+
# OdataDuty::Error renders identically to its message in the text content block.
|
|
99
|
+
def run_tool(action, url:, schema:, context:, query_options:)
|
|
100
|
+
result = Executor.public_send(action, url: url, context: context,
|
|
101
|
+
query_options: query_options, schema: schema)
|
|
102
|
+
MCP::Tool::Response.new([{ type: 'text', text: result.to_s }])
|
|
103
|
+
rescue OdataDuty::Error => e
|
|
104
|
+
MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
class OAS2
|
|
3
|
+
CollectionGetPath = Struct.new(:entity_set, :context) do
|
|
4
|
+
COLLECTION_PARAMETERS = [
|
|
5
|
+
{
|
|
6
|
+
'name' => '$filter',
|
|
7
|
+
'in' => 'query',
|
|
8
|
+
'type' => 'string',
|
|
9
|
+
'description' => 'Filter the results, supporting `and` and flat `or` combinations'
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
'name' => '$search',
|
|
13
|
+
'in' => 'query',
|
|
14
|
+
'type' => 'string',
|
|
15
|
+
'description' => 'Search using structured expressions with AND, OR, NOT operators'
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
'name' => '$select',
|
|
19
|
+
'in' => 'query',
|
|
20
|
+
'type' => 'array',
|
|
21
|
+
'items' => { 'type' => 'string' },
|
|
22
|
+
'collectionFormat' => 'csv',
|
|
23
|
+
'description' => 'Comma-separated list of properties to return'
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
'name' => '$top',
|
|
27
|
+
'in' => 'query',
|
|
28
|
+
'type' => 'integer',
|
|
29
|
+
'description' => 'Number of results to return'
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
'name' => '$skip',
|
|
33
|
+
'in' => 'query',
|
|
34
|
+
'type' => 'integer',
|
|
35
|
+
'description' => 'Number of results to skip'
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
'name' => '$count',
|
|
39
|
+
'in' => 'query',
|
|
40
|
+
'type' => 'boolean',
|
|
41
|
+
'description' => 'Include count of the results'
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
'name' => '$skiptoken',
|
|
45
|
+
'in' => 'query',
|
|
46
|
+
'type' => 'string',
|
|
47
|
+
'description' => 'Token for next page of results'
|
|
48
|
+
}
|
|
49
|
+
].freeze
|
|
50
|
+
COLLECTION_RESPONSE_DEFAULTS = {
|
|
51
|
+
'@odata.nextLink' => {
|
|
52
|
+
'type' => 'string',
|
|
53
|
+
'description' => 'Url for next page of results',
|
|
54
|
+
'x-nullable' => true
|
|
55
|
+
},
|
|
56
|
+
'@odata.count' => {
|
|
57
|
+
'type' => 'integer',
|
|
58
|
+
'description' => 'Total count of results, if $count set to true',
|
|
59
|
+
'x-nullable' => true
|
|
60
|
+
}
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
PARAMETER_REQUIREMENTS = {
|
|
64
|
+
'$top' => :od_top,
|
|
65
|
+
'$count' => :count,
|
|
66
|
+
'$skip' => :od_skip,
|
|
67
|
+
'$skiptoken' => :od_skiptoken,
|
|
68
|
+
'$search' => :od_search
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
def to_oas2
|
|
72
|
+
instance = entity_set.resolver_class.new(context: context, init_args: entity_set.init_args)
|
|
73
|
+
parameters = COLLECTION_PARAMETERS.select do |param|
|
|
74
|
+
!PARAMETER_REQUIREMENTS.key?(param['name']) ||
|
|
75
|
+
instance.respond_to?(PARAMETER_REQUIREMENTS[param['name']])
|
|
76
|
+
end
|
|
77
|
+
{
|
|
78
|
+
'operationId' => "GetCollectionOf#{entity_set.name}",
|
|
79
|
+
'produces' => ['application/json'],
|
|
80
|
+
'parameters' => parameters,
|
|
81
|
+
'responses' => { '200' => oas2_success_response, 'default' => DEFAULT_ERROR_RESPONSE }
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def oas2_success_response
|
|
86
|
+
{ 'description' => 'Collection Response',
|
|
87
|
+
'schema' => {
|
|
88
|
+
'type' => 'object',
|
|
89
|
+
'properties' => { 'value' => {
|
|
90
|
+
'type' => 'array',
|
|
91
|
+
'items' => { '$ref' => "#/definitions/#{entity_set.entity_type_name}" }
|
|
92
|
+
} }.merge(COLLECTION_RESPONSE_DEFAULTS)
|
|
93
|
+
} }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
class OAS2
|
|
3
|
+
class CollectionPostPath
|
|
4
|
+
def self.to_oas2(entity_set)
|
|
5
|
+
path_info = new(entity_set)
|
|
6
|
+
{
|
|
7
|
+
'operationId' => path_info.operation_id,
|
|
8
|
+
'produces' => path_info.produces,
|
|
9
|
+
'parameters' => path_info.parameters,
|
|
10
|
+
'responses' => path_info.responses
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.request_body_definition(entity_set)
|
|
15
|
+
writable = entity_set.entity_type.properties.select(&:settable_on_create?)
|
|
16
|
+
definition = { 'type' => 'object',
|
|
17
|
+
'properties' => writable.to_h { |p| [p.name.to_s, p.to_oas2] } }
|
|
18
|
+
required = writable.reject(&:nullable).map { |p| p.name.to_s }
|
|
19
|
+
definition['required'] = required unless required.empty?
|
|
20
|
+
["#{entity_set.entity_type.name}Create", definition]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(entity_set)
|
|
24
|
+
@entity_set = entity_set
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def operation_id
|
|
28
|
+
"Create#{@entity_set.name}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def produces
|
|
32
|
+
['application/json']
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parameters
|
|
36
|
+
[
|
|
37
|
+
{
|
|
38
|
+
'name' => 'body', 'in' => 'body', 'required' => true, 'schema' => create_schema
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def responses
|
|
44
|
+
{
|
|
45
|
+
'200' => { 'description' => 'Success', 'schema' => entity_type_schema },
|
|
46
|
+
'201' => { 'description' => 'Created', 'schema' => entity_type_schema },
|
|
47
|
+
'default' => { 'description' => 'Unexpected error',
|
|
48
|
+
'schema' => { '$ref' => '#/definitions/Error' } }
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def entity_type_schema
|
|
55
|
+
{ '$ref' => "#/definitions/#{@entity_set.entity_type.name}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def create_schema
|
|
59
|
+
{ '$ref' => "#/definitions/#{@entity_set.entity_type.name}Create" }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
class OAS2
|
|
3
|
+
class IndividualDeletePath
|
|
4
|
+
def self.to_oas2(entity_set)
|
|
5
|
+
path_info = new(entity_set)
|
|
6
|
+
{
|
|
7
|
+
'operationId' => path_info.operation_id,
|
|
8
|
+
'parameters' => path_info.parameters,
|
|
9
|
+
'responses' => path_info.responses
|
|
10
|
+
}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(entity_set)
|
|
14
|
+
@entity_set = entity_set
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def operation_id
|
|
18
|
+
"Delete#{@entity_set.name}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parameters
|
|
22
|
+
[
|
|
23
|
+
{ 'name' => 'id', 'in' => 'path', 'required' => true, 'type' => id_type }
|
|
24
|
+
]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def responses
|
|
28
|
+
{
|
|
29
|
+
'204' => { 'description' => 'No Content' },
|
|
30
|
+
'default' => { 'description' => 'Unexpected error',
|
|
31
|
+
'schema' => { '$ref' => '#/definitions/Error' } }
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def id_type
|
|
38
|
+
@entity_set.entity_type.integer_property_ref? ? 'integer' : 'string'
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
class OAS2
|
|
3
|
+
class IndividualGetPath < SimpleDelegator
|
|
4
|
+
def to_oas2
|
|
5
|
+
{
|
|
6
|
+
'operationId' => "GetIndividual#{name}ById",
|
|
7
|
+
'produces' => ['application/json'],
|
|
8
|
+
'parameters' => oas2_parameters,
|
|
9
|
+
'responses' => oas2_responses
|
|
10
|
+
}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def oas2_parameters
|
|
14
|
+
[
|
|
15
|
+
{
|
|
16
|
+
'name' => 'id',
|
|
17
|
+
'in' => 'path',
|
|
18
|
+
'required' => true,
|
|
19
|
+
'type' => entity_type.integer_property_ref? ? 'integer' : 'string'
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def oas2_responses
|
|
25
|
+
{
|
|
26
|
+
'200' => {
|
|
27
|
+
'description' => 'Individual Response',
|
|
28
|
+
'schema' => {
|
|
29
|
+
'$ref' => "#/definitions/#{entity_type_name}"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
'default' => DEFAULT_ERROR_RESPONSE
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module OdataDuty
|
|
2
|
+
class OAS2
|
|
3
|
+
class IndividualPatchPath
|
|
4
|
+
def self.to_oas2(entity_set)
|
|
5
|
+
path_info = new(entity_set)
|
|
6
|
+
{
|
|
7
|
+
'operationId' => path_info.operation_id,
|
|
8
|
+
'produces' => path_info.produces,
|
|
9
|
+
'parameters' => path_info.parameters,
|
|
10
|
+
'responses' => path_info.responses
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.request_body_definition(entity_set)
|
|
15
|
+
writable = entity_set.entity_type.properties.select(&:settable_on_update?)
|
|
16
|
+
definition = { 'type' => 'object',
|
|
17
|
+
'properties' => writable.to_h { |p| [p.name.to_s, p.to_oas2] } }
|
|
18
|
+
["#{entity_set.entity_type.name}Update", definition]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(entity_set)
|
|
22
|
+
@entity_set = entity_set
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def operation_id
|
|
26
|
+
"Update#{@entity_set.name}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def produces
|
|
30
|
+
['application/json']
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parameters
|
|
34
|
+
[
|
|
35
|
+
{ 'name' => 'id', 'in' => 'path', 'required' => true, 'type' => id_type },
|
|
36
|
+
{ 'name' => 'body', 'in' => 'body', 'required' => true, 'schema' => update_schema }
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def responses
|
|
41
|
+
{
|
|
42
|
+
'200' => { 'description' => 'Success', 'schema' => entity_type_schema },
|
|
43
|
+
'default' => { 'description' => 'Unexpected error',
|
|
44
|
+
'schema' => { '$ref' => '#/definitions/Error' } }
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def id_type
|
|
51
|
+
@entity_set.entity_type.integer_property_ref? ? 'integer' : 'string'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def entity_type_schema
|
|
55
|
+
{ '$ref' => "#/definitions/#{@entity_set.entity_type.name}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def update_schema
|
|
59
|
+
{ '$ref' => "#/definitions/#{@entity_set.entity_type.name}Update" }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require_relative 'context_wrapper'
|
|
2
|
+
require_relative 'schema_builder/endpoint'
|
|
3
|
+
|
|
4
|
+
module OdataDuty
|
|
5
|
+
class OAS2
|
|
6
|
+
def self.build_json(schema, context: nil)
|
|
7
|
+
builder = new(schema, context: context)
|
|
8
|
+
builder.add_error_definition
|
|
9
|
+
builder.add_enum_definitions
|
|
10
|
+
builder.add_complex_definitions
|
|
11
|
+
builder.add_request_body_definitions
|
|
12
|
+
builder.add_collection_paths
|
|
13
|
+
builder.add_individual_paths
|
|
14
|
+
builder.hash
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :hash, :schema, :context, :info, :paths, :definitions
|
|
18
|
+
|
|
19
|
+
def initialize(schema, context:)
|
|
20
|
+
@schema = schema
|
|
21
|
+
@context = context
|
|
22
|
+
@info = {}
|
|
23
|
+
@paths = {}
|
|
24
|
+
@definitions = {}
|
|
25
|
+
@hash = { 'swagger' => '2.0', 'info' => info, 'host' => schema.host,
|
|
26
|
+
'schemes' => [schema.scheme], 'basePath' => schema.base_path,
|
|
27
|
+
'paths' => paths, 'definitions' => definitions }
|
|
28
|
+
info['version'] = schema.version if schema.version
|
|
29
|
+
info['title'] = schema.title if schema.title
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
ERROR_PROPERTIES = {
|
|
33
|
+
'code' => { 'type' => 'string', 'description' => 'A service-defined error code.' },
|
|
34
|
+
'message' => { 'type' => 'string', 'description' => 'A human-readable message.' },
|
|
35
|
+
'target' => { 'type' => 'string', 'description' => 'The target of the error.',
|
|
36
|
+
'x-nullable' => true }
|
|
37
|
+
}.freeze
|
|
38
|
+
def add_error_definition
|
|
39
|
+
definitions['Error'] = {
|
|
40
|
+
'type' => 'object',
|
|
41
|
+
'properties' => {
|
|
42
|
+
'error' => {
|
|
43
|
+
'type' => 'object',
|
|
44
|
+
'properties' => ERROR_PROPERTIES
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_enum_definitions
|
|
51
|
+
schema.enum_types.each do |enum_type|
|
|
52
|
+
definitions[enum_type.name] = enum_type.to_oas2
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_complex_definitions
|
|
57
|
+
(schema.complex_types + schema.entity_types).each do |complex_type|
|
|
58
|
+
definitions[complex_type.name] = complex_type.to_oas2
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def add_request_body_definitions
|
|
63
|
+
schema.collection_entity_sets.select(&:supports_create?).each do |entity_set|
|
|
64
|
+
register_definition(CollectionPostPath.request_body_definition(entity_set))
|
|
65
|
+
end
|
|
66
|
+
schema.individual_entity_sets.select(&:supports_update?).each do |entity_set|
|
|
67
|
+
register_definition(IndividualPatchPath.request_body_definition(entity_set))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def add_collection_paths
|
|
72
|
+
schema.collection_entity_sets.each do |entity_set|
|
|
73
|
+
path = { 'get' => CollectionGetPath.new(entity_set, wrap_context(entity_set)).to_oas2 }
|
|
74
|
+
path['post'] = CollectionPostPath.to_oas2(entity_set) if entity_set.supports_create?
|
|
75
|
+
paths["/#{entity_set.url}"] = path
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def add_individual_paths
|
|
80
|
+
schema.individual_entity_sets.each do |entity_set|
|
|
81
|
+
path = { 'get' => IndividualGetPath.new(entity_set).to_oas2 }
|
|
82
|
+
path['patch'] = IndividualPatchPath.to_oas2(entity_set) if entity_set.supports_update?
|
|
83
|
+
path['delete'] = IndividualDeletePath.to_oas2(entity_set) if entity_set.supports_delete?
|
|
84
|
+
paths["/#{entity_set.url}({id})"] = path
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
DEFAULT_ERROR_RESPONSE = {
|
|
89
|
+
'description' => 'Unexpected error',
|
|
90
|
+
'schema' => {
|
|
91
|
+
'$ref' => '#/definitions/Error'
|
|
92
|
+
}
|
|
93
|
+
}.freeze
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def register_definition((name, definition))
|
|
98
|
+
definitions[name] = definition
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def wrap_context(entity_set)
|
|
102
|
+
ContextWrapper.new(@context, base_url: schema.base_url,
|
|
103
|
+
endpoint: SchemaBuilder::Endpoint.new(entity_set),
|
|
104
|
+
query_options: nil)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
require 'odata_duty/oas2/collection_get_path'
|
|
110
|
+
require 'odata_duty/oas2/collection_post_path'
|
|
111
|
+
require 'odata_duty/oas2/individual_get_path'
|
|
112
|
+
require 'odata_duty/oas2/individual_patch_path'
|
|
113
|
+
require 'odata_duty/oas2/individual_delete_path'
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
require 'parslet'
|
|
2
|
+
|
|
3
|
+
module OdataDuty
|
|
4
|
+
class SearchExpression
|
|
5
|
+
attr_reader :terms, :operator
|
|
6
|
+
|
|
7
|
+
def initialize(terms, operator = :and)
|
|
8
|
+
@terms = terms
|
|
9
|
+
@operator = operator
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def or?
|
|
13
|
+
@operator == :or
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def and?
|
|
17
|
+
@operator == :and
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.parse(search_string)
|
|
21
|
+
return new([]) unless search_string.match?(/\S/)
|
|
22
|
+
|
|
23
|
+
parse_tree = build_parse_tree(search_string)
|
|
24
|
+
validate_parse_tree(parse_tree)
|
|
25
|
+
transform(parse_tree)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.build_parse_tree(search_string)
|
|
29
|
+
parser = ParsletSearchExpressionParser.new
|
|
30
|
+
parser.parse(search_string)
|
|
31
|
+
rescue Parslet::ParseFailed => e
|
|
32
|
+
if search_string.include?('(') || search_string.include?(')')
|
|
33
|
+
raise NoImplementationError, 'Parentheses are not supported'
|
|
34
|
+
end
|
|
35
|
+
if search_string.include?(' AND ') && search_string.include?(' OR ')
|
|
36
|
+
raise NoImplementationError, 'Mixed AND/OR operators are not supported'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
raise InvalidQueryOptionError, "Invalid search expression: #{e}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.validate_parse_tree(parse_tree)
|
|
43
|
+
and_sub_tree = parse_tree[:explicit_and_expr] || parse_tree[:implicit_and_expr]
|
|
44
|
+
return unless and_sub_tree
|
|
45
|
+
|
|
46
|
+
contains_or = and_sub_tree.any? { |t| t.dig(:term, :word) == 'OR' }
|
|
47
|
+
raise NoImplementationError, 'Mixed AND/OR operators are not supported' if contains_or
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.transform(parse_tree)
|
|
51
|
+
transformer = ParsletSearchExpressionTransformer.new
|
|
52
|
+
transformer.apply(parse_tree)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class SearchTerm
|
|
57
|
+
attr_reader :value, :negated
|
|
58
|
+
|
|
59
|
+
def initialize(value, negated:)
|
|
60
|
+
@value = value
|
|
61
|
+
@negated = negated
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def not?
|
|
65
|
+
@negated
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_s
|
|
69
|
+
prefix = ('NOT ' if @negated)
|
|
70
|
+
quoted = @value.include?(' ') ? "\"#{@value}\"" : @value
|
|
71
|
+
"#{prefix}#{quoted}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class ParsletSearchExpressionParser < Parslet::Parser
|
|
76
|
+
# Basic elements
|
|
77
|
+
rule(:space) { match('\s').repeat(1) }
|
|
78
|
+
rule(:space?) { space.maybe }
|
|
79
|
+
|
|
80
|
+
# Word characters - alphanumeric, dots, commas, hyphens, underscores
|
|
81
|
+
rule(:word_char) { match('[a-zA-Z0-9,.\-_]') }
|
|
82
|
+
rule(:word) { word_char.repeat(1).as(:word) }
|
|
83
|
+
|
|
84
|
+
# Quoted phrases
|
|
85
|
+
rule(:quoted_phrase) do
|
|
86
|
+
str('"') >>
|
|
87
|
+
(str('"').absent? >> any).repeat.as(:phrase) >>
|
|
88
|
+
str('"')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# NOT operator
|
|
92
|
+
rule(:not_operator) { str('NOT') >> space }
|
|
93
|
+
|
|
94
|
+
# Basic term (word or quoted phrase)
|
|
95
|
+
rule(:basic_term) { quoted_phrase | word }
|
|
96
|
+
|
|
97
|
+
# Term with optional negation
|
|
98
|
+
rule(:term) do
|
|
99
|
+
(not_operator >> basic_term).as(:negated_term) |
|
|
100
|
+
basic_term.as(:term)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Operators
|
|
104
|
+
rule(:and_operator) { space >> str('AND') >> space }
|
|
105
|
+
rule(:or_operator) { space >> str('OR') >> space }
|
|
106
|
+
rule(:implicit_and) { space }
|
|
107
|
+
|
|
108
|
+
# Simplified expression rules
|
|
109
|
+
rule(:or_expression) { term >> (or_operator >> term).repeat(1) }
|
|
110
|
+
rule(:explicit_and_expression) { term >> (and_operator >> term).repeat(1) }
|
|
111
|
+
rule(:implicit_and_expression) { term >> (implicit_and >> term).repeat(1) }
|
|
112
|
+
rule(:single_term_expression) { term }
|
|
113
|
+
|
|
114
|
+
# Main expression - try each pattern
|
|
115
|
+
rule(:expression) do
|
|
116
|
+
or_expression.as(:or_expr) |
|
|
117
|
+
explicit_and_expression.as(:explicit_and_expr) |
|
|
118
|
+
implicit_and_expression.as(:implicit_and_expr) |
|
|
119
|
+
single_term_expression.as(:single_term)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Root rule
|
|
123
|
+
rule(:search_expression) { space? >> expression >> space? }
|
|
124
|
+
|
|
125
|
+
root(:search_expression)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class ParsletSearchExpressionTransformer < Parslet::Transform
|
|
129
|
+
rule(word: simple(:word)) { word.to_s }
|
|
130
|
+
rule(phrase: simple(:phrase)) { phrase.to_s }
|
|
131
|
+
|
|
132
|
+
rule(term: simple(:term)) do
|
|
133
|
+
SearchTerm.new(term.to_s, negated: false)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
rule(negated_term: simple(:term)) do
|
|
137
|
+
SearchTerm.new(term.to_s, negated: true)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Single term expressions
|
|
141
|
+
rule(single_term: simple(:term)) do
|
|
142
|
+
SearchExpression.new([term], :and)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# OR expressions
|
|
146
|
+
rule(or_expr: sequence(:terms)) do
|
|
147
|
+
# Multiple terms in OR expression
|
|
148
|
+
SearchExpression.new(terms, :or)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Explicit AND expressions
|
|
152
|
+
rule(explicit_and_expr: sequence(:terms)) do
|
|
153
|
+
# Multiple terms in explicit AND expression
|
|
154
|
+
SearchExpression.new(terms, :and)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Implicit AND expressions
|
|
158
|
+
rule(implicit_and_expr: sequence(:terms)) do
|
|
159
|
+
# Multiple terms in implicit AND expression
|
|
160
|
+
SearchExpression.new(terms, :and)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|