@gavdi/cap-mcp 0.9.0 → 0.9.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.
- package/lib/annotations/constants.js +22 -4
- package/lib/annotations/parser.js +41 -3
- package/lib/annotations/structures.js +111 -0
- package/lib/annotations/utils.js +55 -0
- package/lib/config/env-sanitizer.js +78 -0
- package/lib/config/json-parser.js +165 -0
- package/lib/config/loader.js +20 -12
- package/lib/logger.js +8 -0
- package/lib/mcp/constants.js +16 -0
- package/lib/mcp/custom-resource-template.js +156 -0
- package/lib/mcp/customResourceTemplate.js +156 -0
- package/lib/mcp/factory.js +21 -17
- package/lib/mcp/prompts.js +12 -2
- package/lib/mcp/resources.js +89 -25
- package/lib/mcp/session-manager.js +92 -0
- package/lib/mcp/tools.js +82 -13
- package/lib/mcp/utils.js +12 -3
- package/lib/mcp/validation.js +318 -0
- package/lib/mcp.js +70 -42
- package/package.json +18 -6
@@ -0,0 +1,92 @@
|
|
1
|
+
"use strict";
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
3
|
+
exports.McpSessionManager = void 0;
|
4
|
+
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
5
|
+
const crypto_1 = require("crypto");
|
6
|
+
const env_sanitizer_1 = require("../config/env-sanitizer");
|
7
|
+
const logger_1 = require("../logger");
|
8
|
+
const factory_1 = require("./factory");
|
9
|
+
/**
|
10
|
+
* Manages active MCP server sessions and their lifecycle
|
11
|
+
* Handles session creation, storage, retrieval, and cleanup for MCP protocol communication
|
12
|
+
*/
|
13
|
+
class McpSessionManager {
|
14
|
+
/** Map storing active sessions by their unique session IDs */
|
15
|
+
sessions;
|
16
|
+
/**
|
17
|
+
* Creates a new session manager with empty session storage
|
18
|
+
*/
|
19
|
+
constructor() {
|
20
|
+
this.sessions = new Map();
|
21
|
+
}
|
22
|
+
/**
|
23
|
+
* Retrieves the complete map of active sessions
|
24
|
+
* @returns Map of session IDs to their corresponding session objects
|
25
|
+
*/
|
26
|
+
getSessions() {
|
27
|
+
return this.sessions;
|
28
|
+
}
|
29
|
+
/**
|
30
|
+
* Checks if a session exists for the given session ID
|
31
|
+
* @param sessionID - Unique identifier for the session
|
32
|
+
* @returns True if session exists, false otherwise
|
33
|
+
*/
|
34
|
+
hasSession(sessionID) {
|
35
|
+
return this.sessions.has(sessionID);
|
36
|
+
}
|
37
|
+
/**
|
38
|
+
* Retrieves a specific session by its ID
|
39
|
+
* @param sessionID - Unique identifier for the session
|
40
|
+
* @returns Session object if found, undefined otherwise
|
41
|
+
*/
|
42
|
+
getSession(sessionID) {
|
43
|
+
return this.sessions.get(sessionID);
|
44
|
+
}
|
45
|
+
/**
|
46
|
+
* Creates a new MCP session with server and transport configuration
|
47
|
+
* Initializes MCP server with provided annotations and establishes transport connection
|
48
|
+
* @param config - CAP configuration for the MCP server
|
49
|
+
* @param annotations - Optional parsed MCP annotations for resources, tools, and prompts
|
50
|
+
* @returns Promise resolving to the created session object
|
51
|
+
*/
|
52
|
+
async createSession(config, annotations) {
|
53
|
+
logger_1.LOGGER.debug("Initialize session request received");
|
54
|
+
const server = (0, factory_1.createMcpServer)(config, annotations);
|
55
|
+
const transport = this.createTransport(server);
|
56
|
+
await server.connect(transport);
|
57
|
+
return { server, transport };
|
58
|
+
}
|
59
|
+
/**
|
60
|
+
* Creates and configures HTTP transport for MCP communication
|
61
|
+
* Sets up session ID generation, response format, and event handlers
|
62
|
+
* @param server - MCP server instance to associate with the transport
|
63
|
+
* @returns Configured StreamableHTTPServerTransport instance
|
64
|
+
*/
|
65
|
+
createTransport(server) {
|
66
|
+
const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
67
|
+
sessionIdGenerator: () => (0, crypto_1.randomUUID)(),
|
68
|
+
enableJsonResponse: (0, env_sanitizer_1.isTestEnvironment)(),
|
69
|
+
onsessioninitialized: (sid) => {
|
70
|
+
logger_1.LOGGER.debug("Session initialized with ID: ", sid);
|
71
|
+
this.sessions.set(sid, {
|
72
|
+
server: server,
|
73
|
+
transport: transport,
|
74
|
+
});
|
75
|
+
},
|
76
|
+
});
|
77
|
+
transport.onclose = () => this.onCloseSession(transport);
|
78
|
+
return transport;
|
79
|
+
}
|
80
|
+
/**
|
81
|
+
* Handles session cleanup when transport connection closes
|
82
|
+
* Removes the session from active sessions map when connection terminates
|
83
|
+
* @param transport - Transport instance that was closed
|
84
|
+
*/
|
85
|
+
onCloseSession(transport) {
|
86
|
+
if (!transport.sessionId || !this.sessions.has(transport.sessionId)) {
|
87
|
+
return;
|
88
|
+
}
|
89
|
+
this.sessions.delete(transport.sessionId);
|
90
|
+
}
|
91
|
+
}
|
92
|
+
exports.McpSessionManager = McpSessionManager;
|
package/lib/mcp/tools.js
CHANGED
@@ -4,11 +4,14 @@ exports.assignToolToServer = assignToolToServer;
|
|
4
4
|
const utils_1 = require("./utils");
|
5
5
|
const logger_1 = require("../logger");
|
6
6
|
const constants_1 = require("./constants");
|
7
|
+
const zod_1 = require("zod");
|
7
8
|
/* @ts-ignore */
|
8
9
|
const cds = global.cds || require("@sap/cds"); // This is a work around for missing cds context
|
9
10
|
/**
|
10
|
-
*
|
11
|
-
*
|
11
|
+
* Registers a CAP function or action as an executable MCP tool
|
12
|
+
* Handles both bound (entity-level) and unbound (service-level) operations
|
13
|
+
* @param model - The tool annotation containing operation metadata and parameters
|
14
|
+
* @param server - The MCP server instance to register the tool with
|
12
15
|
*/
|
13
16
|
function assignToolToServer(model, server) {
|
14
17
|
logger_1.LOGGER.debug("Adding tool", model);
|
@@ -21,7 +24,11 @@ function assignToolToServer(model, server) {
|
|
21
24
|
assignUnboundOperation(parameters, model, server);
|
22
25
|
}
|
23
26
|
/**
|
24
|
-
*
|
27
|
+
* Registers a bound operation that operates on a specific entity instance
|
28
|
+
* Requires entity key parameters in addition to operation parameters
|
29
|
+
* @param params - Zod schema definitions for operation parameters
|
30
|
+
* @param model - Tool annotation with bound operation metadata
|
31
|
+
* @param server - MCP server instance to register with
|
25
32
|
*/
|
26
33
|
function assignBoundOperation(params, model, server) {
|
27
34
|
if (!model.keyTypeMap || model.keyTypeMap.size <= 0) {
|
@@ -29,7 +36,12 @@ function assignBoundOperation(params, model, server) {
|
|
29
36
|
throw new Error("Bound operation cannot be assigned to tool list, missing keys");
|
30
37
|
}
|
31
38
|
const keys = buildToolParameters(model.keyTypeMap);
|
32
|
-
|
39
|
+
const inputSchema = buildZodSchema({ ...keys, ...params });
|
40
|
+
server.registerTool(model.name, {
|
41
|
+
title: model.name,
|
42
|
+
description: model.description,
|
43
|
+
inputSchema: inputSchema,
|
44
|
+
}, async (args) => {
|
33
45
|
const service = cds.services[model.serviceName];
|
34
46
|
if (!service) {
|
35
47
|
logger_1.LOGGER.error("Invalid CAP service - undefined");
|
@@ -45,7 +57,7 @@ function assignBoundOperation(params, model, server) {
|
|
45
57
|
}
|
46
58
|
const operationInput = {};
|
47
59
|
const operationKeys = {};
|
48
|
-
for (const [k, v] of Object.entries(
|
60
|
+
for (const [k, v] of Object.entries(args)) {
|
49
61
|
if (model.keyTypeMap?.has(k)) {
|
50
62
|
operationKeys[k] = v;
|
51
63
|
}
|
@@ -61,16 +73,28 @@ function assignBoundOperation(params, model, server) {
|
|
61
73
|
});
|
62
74
|
return {
|
63
75
|
content: Array.isArray(response)
|
64
|
-
? response.map((el) => ({
|
65
|
-
|
76
|
+
? response.map((el) => ({
|
77
|
+
type: "text",
|
78
|
+
text: formatResponseValue(el),
|
79
|
+
}))
|
80
|
+
: [{ type: "text", text: formatResponseValue(response) }],
|
66
81
|
};
|
67
82
|
});
|
68
83
|
}
|
69
84
|
/**
|
70
|
-
*
|
85
|
+
* Registers an unbound operation that operates at the service level
|
86
|
+
* Does not require entity keys, only operation parameters
|
87
|
+
* @param params - Zod schema definitions for operation parameters
|
88
|
+
* @param model - Tool annotation with unbound operation metadata
|
89
|
+
* @param server - MCP server instance to register with
|
71
90
|
*/
|
72
91
|
function assignUnboundOperation(params, model, server) {
|
73
|
-
|
92
|
+
const inputSchema = buildZodSchema(params);
|
93
|
+
server.registerTool(model.name, {
|
94
|
+
title: model.name,
|
95
|
+
description: model.description,
|
96
|
+
inputSchema: inputSchema,
|
97
|
+
}, async (args) => {
|
74
98
|
const service = cds.services[model.serviceName];
|
75
99
|
if (!service) {
|
76
100
|
logger_1.LOGGER.error("Invalid CAP service - undefined");
|
@@ -84,16 +108,21 @@ function assignUnboundOperation(params, model, server) {
|
|
84
108
|
],
|
85
109
|
};
|
86
110
|
}
|
87
|
-
const response = await service.send(model.target,
|
111
|
+
const response = await service.send(model.target, args);
|
88
112
|
return {
|
89
113
|
content: Array.isArray(response)
|
90
|
-
? response.map((el) => ({
|
91
|
-
|
114
|
+
? response.map((el) => ({
|
115
|
+
type: "text",
|
116
|
+
text: formatResponseValue(el),
|
117
|
+
}))
|
118
|
+
: [{ type: "text", text: formatResponseValue(response) }],
|
92
119
|
};
|
93
120
|
});
|
94
121
|
}
|
95
122
|
/**
|
96
|
-
*
|
123
|
+
* Converts a map of CDS parameter types to MCP parameter schema definitions
|
124
|
+
* @param params - Map of parameter names to their CDS type strings
|
125
|
+
* @returns Record of parameter names to Zod schema types
|
97
126
|
*/
|
98
127
|
function buildToolParameters(params) {
|
99
128
|
if (!params || params.size <= 0)
|
@@ -104,3 +133,43 @@ function buildToolParameters(params) {
|
|
104
133
|
}
|
105
134
|
return result;
|
106
135
|
}
|
136
|
+
/**
|
137
|
+
* Converts a value to a string representation suitable for MCP responses
|
138
|
+
* Handles objects and arrays by JSON stringifying them instead of using String()
|
139
|
+
* @param value - The value to convert to string
|
140
|
+
* @returns String representation of the value
|
141
|
+
*/
|
142
|
+
function formatResponseValue(value) {
|
143
|
+
if (value === null || value === undefined) {
|
144
|
+
return String(value);
|
145
|
+
}
|
146
|
+
if (typeof value === "object") {
|
147
|
+
try {
|
148
|
+
return JSON.stringify(value, null, 2);
|
149
|
+
}
|
150
|
+
catch (error) {
|
151
|
+
// Fallback to String() if JSON.stringify fails (e.g., circular references)
|
152
|
+
return String(value);
|
153
|
+
}
|
154
|
+
}
|
155
|
+
return String(value);
|
156
|
+
}
|
157
|
+
/**
|
158
|
+
* Constructs a complete Zod schema object for MCP tool input validation
|
159
|
+
* @param params - Record of parameter names to Zod schema types
|
160
|
+
* @returns Zod schema record suitable for MCP tool registration
|
161
|
+
*/
|
162
|
+
function buildZodSchema(params) {
|
163
|
+
const schema = {};
|
164
|
+
for (const [key, zodType] of Object.entries(params)) {
|
165
|
+
// The parameter is already a Zod type from determineMcpParameterType
|
166
|
+
if (zodType && typeof zodType === "object" && "describe" in zodType) {
|
167
|
+
schema[key] = zodType;
|
168
|
+
}
|
169
|
+
else {
|
170
|
+
// Fallback to string if not a valid Zod type
|
171
|
+
schema[key] = zod_1.z.string().describe(`Parameter: ${key}`);
|
172
|
+
}
|
173
|
+
}
|
174
|
+
return schema;
|
175
|
+
}
|
package/lib/mcp/utils.js
CHANGED
@@ -6,7 +6,9 @@ exports.writeODataDescriptionForResource = writeODataDescriptionForResource;
|
|
6
6
|
const constants_1 = require("./constants");
|
7
7
|
const zod_1 = require("zod");
|
8
8
|
/**
|
9
|
-
*
|
9
|
+
* Converts a CDS type string to the corresponding Zod schema type
|
10
|
+
* @param cdsType - The CDS type name (e.g., 'String', 'Integer')
|
11
|
+
* @returns Zod schema instance for the given type
|
10
12
|
*/
|
11
13
|
function determineMcpParameterType(cdsType) {
|
12
14
|
switch (cdsType) {
|
@@ -19,8 +21,10 @@ function determineMcpParameterType(cdsType) {
|
|
19
21
|
}
|
20
22
|
}
|
21
23
|
/**
|
22
|
-
*
|
23
|
-
*
|
24
|
+
* Handles incoming MCP session requests by validating session IDs and routing to appropriate session
|
25
|
+
* @param req - Express request object containing session headers
|
26
|
+
* @param res - Express response object for sending responses
|
27
|
+
* @param sessions - Map of active MCP sessions keyed by session ID
|
24
28
|
*/
|
25
29
|
async function handleMcpSessionRequest(req, res, sessions) {
|
26
30
|
const sessionIdHeader = req.headers[constants_1.MCP_SESSION_HEADER];
|
@@ -35,6 +39,11 @@ async function handleMcpSessionRequest(req, res, sessions) {
|
|
35
39
|
}
|
36
40
|
await session.transport.handleRequest(req, res);
|
37
41
|
}
|
42
|
+
/**
|
43
|
+
* Writes a detailed OData description for a resource including available query parameters and properties
|
44
|
+
* @param model - The resource annotation to generate description for
|
45
|
+
* @returns Formatted description string with OData query syntax examples
|
46
|
+
*/
|
38
47
|
function writeODataDescriptionForResource(model) {
|
39
48
|
let description = `${model.description}.${constants_1.NEW_LINE}`;
|
40
49
|
description += `Should be queried using OData v4 query style using the following allowed parameters.${constants_1.NEW_LINE}`;
|
@@ -0,0 +1,318 @@
|
|
1
|
+
"use strict";
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
3
|
+
exports.ODataValidationError = exports.ODataQueryValidator = exports.ODataQueryValidationSchemas = void 0;
|
4
|
+
const zod_1 = require("zod");
|
5
|
+
const logger_1 = require("../logger");
|
6
|
+
/**
|
7
|
+
* Comprehensive validation system for OData query parameters with security controls
|
8
|
+
* Provides schema validation, injection attack prevention, and type safety
|
9
|
+
*/
|
10
|
+
// Allowed OData operators
|
11
|
+
const ALLOWED_OPERATORS = new Set([
|
12
|
+
"eq",
|
13
|
+
"ne",
|
14
|
+
"gt",
|
15
|
+
"ge",
|
16
|
+
"lt",
|
17
|
+
"le",
|
18
|
+
"and",
|
19
|
+
"or",
|
20
|
+
"not",
|
21
|
+
"contains",
|
22
|
+
"startswith",
|
23
|
+
"endswith",
|
24
|
+
"indexof",
|
25
|
+
"length",
|
26
|
+
"substring",
|
27
|
+
"tolower",
|
28
|
+
"toupper",
|
29
|
+
"trim",
|
30
|
+
]);
|
31
|
+
// Forbidden patterns that could indicate injection attempts
|
32
|
+
const FORBIDDEN_PATTERNS = [
|
33
|
+
/;/g, // SQL statement terminator
|
34
|
+
/--/g, // SQL comment
|
35
|
+
/\/\*/g, // Multi-line comment start
|
36
|
+
/\*\//g, // Multi-line comment end
|
37
|
+
/xp_/gi, // Extended procedures
|
38
|
+
/sp_/gi, // Stored procedures
|
39
|
+
/exec/gi, // Execute command
|
40
|
+
/union/gi, // Union queries
|
41
|
+
/insert/gi, // Insert statements
|
42
|
+
/update/gi, // Update statements
|
43
|
+
/delete/gi, // Delete statements
|
44
|
+
/drop/gi, // Drop statements
|
45
|
+
/create/gi, // Create statements
|
46
|
+
/alter/gi, // Alter statements
|
47
|
+
/script/gi, // Script tags
|
48
|
+
/javascript/gi, // JavaScript
|
49
|
+
/eval/gi, // Eval function
|
50
|
+
/expression/gi, // Expression evaluation
|
51
|
+
/\bor\s+\d+\s*=\s*\d+/gi, // SQL injection pattern like \"OR 1=1\"
|
52
|
+
/\band\s+\d+\s*=\s*\d+/gi, // SQL injection pattern like \"AND 1=1\"
|
53
|
+
];
|
54
|
+
// Validation schemas
|
55
|
+
exports.ODataQueryValidationSchemas = {
|
56
|
+
top: zod_1.z.number().int().min(1).max(1000),
|
57
|
+
skip: zod_1.z.number().int().min(0),
|
58
|
+
select: zod_1.z
|
59
|
+
.string()
|
60
|
+
.min(1)
|
61
|
+
.max(500)
|
62
|
+
.regex(/^[a-zA-Z_][a-zA-Z0-9_,\s]*$/),
|
63
|
+
orderby: zod_1.z
|
64
|
+
.string()
|
65
|
+
.min(1)
|
66
|
+
.max(200)
|
67
|
+
.regex(/^[a-zA-Z_][a-zA-Z0-9_\s]+(asc|desc)?(,\s*[a-zA-Z_][a-zA-Z0-9_\s]+(asc|desc)?)*$/i),
|
68
|
+
filter: zod_1.z.string().min(1).max(1000),
|
69
|
+
};
|
70
|
+
/**
|
71
|
+
* Validates and sanitizes OData query parameters with comprehensive security checks
|
72
|
+
* Prevents injection attacks, validates property references, and ensures type safety
|
73
|
+
*/
|
74
|
+
class ODataQueryValidator {
|
75
|
+
allowedProperties;
|
76
|
+
allowedTypes;
|
77
|
+
/**
|
78
|
+
* Creates a new OData query validator for a specific entity
|
79
|
+
* @param properties - Map of allowed entity properties to their CDS types
|
80
|
+
*/
|
81
|
+
constructor(properties) {
|
82
|
+
this.allowedProperties = new Set(properties.keys());
|
83
|
+
this.allowedTypes = new Map(properties);
|
84
|
+
}
|
85
|
+
/**
|
86
|
+
* Validates and parses the $top query parameter
|
87
|
+
* @param value - String value of the $top parameter
|
88
|
+
* @returns Validated integer value between 1 and 1000
|
89
|
+
* @throws Error if value is invalid or out of range
|
90
|
+
*/
|
91
|
+
validateTop(value) {
|
92
|
+
const parsed = parseFloat(value);
|
93
|
+
if (isNaN(parsed) || !Number.isInteger(parsed)) {
|
94
|
+
throw new Error(`Invalid top parameter: ${value}`);
|
95
|
+
}
|
96
|
+
return exports.ODataQueryValidationSchemas.top.parse(parsed);
|
97
|
+
}
|
98
|
+
/**
|
99
|
+
* Validates and parses the $skip query parameter
|
100
|
+
* @param value - String value of the $skip parameter
|
101
|
+
* @returns Validated non-negative integer value
|
102
|
+
* @throws Error if value is invalid or negative
|
103
|
+
*/
|
104
|
+
validateSkip(value) {
|
105
|
+
const parsed = parseFloat(value);
|
106
|
+
if (isNaN(parsed) || !Number.isInteger(parsed)) {
|
107
|
+
throw new Error(`Invalid skip parameter: ${value}`);
|
108
|
+
}
|
109
|
+
return exports.ODataQueryValidationSchemas.skip.parse(parsed);
|
110
|
+
}
|
111
|
+
/**
|
112
|
+
* Validates and sanitizes the $select query parameter
|
113
|
+
* @param value - Comma-separated list of property names
|
114
|
+
* @returns Array of validated property names
|
115
|
+
* @throws Error if any property is invalid or not allowed
|
116
|
+
*/
|
117
|
+
validateSelect(value) {
|
118
|
+
const decoded = decodeURIComponent(value);
|
119
|
+
const validated = exports.ODataQueryValidationSchemas.select.parse(decoded);
|
120
|
+
const columns = validated.split(",").map((col) => col.trim());
|
121
|
+
// Validate each column exists in entity
|
122
|
+
for (const column of columns) {
|
123
|
+
if (!this.allowedProperties.has(column)) {
|
124
|
+
throw new Error(`Invalid select column: ${column}. Allowed columns: ${Array.from(this.allowedProperties).join(", ")}`);
|
125
|
+
}
|
126
|
+
}
|
127
|
+
return columns;
|
128
|
+
}
|
129
|
+
/**
|
130
|
+
* Validates and sanitizes the $orderby query parameter
|
131
|
+
* @param value - Order by clause with property names and optional asc/desc
|
132
|
+
* @returns Validated order by string
|
133
|
+
* @throws Error if any property is invalid or not allowed
|
134
|
+
*/
|
135
|
+
validateOrderBy(value) {
|
136
|
+
const decoded = decodeURIComponent(value);
|
137
|
+
const validated = exports.ODataQueryValidationSchemas.orderby.parse(decoded);
|
138
|
+
// Extract property names and validate they exist
|
139
|
+
const orderClauses = validated.split(",").map((clause) => clause.trim());
|
140
|
+
for (const clause of orderClauses) {
|
141
|
+
const parts = clause.split(/\s+/);
|
142
|
+
const property = parts[0];
|
143
|
+
if (!this.allowedProperties.has(property)) {
|
144
|
+
throw new Error(`Invalid orderby property: ${property}. Allowed properties: ${Array.from(this.allowedProperties).join(", ")}`);
|
145
|
+
}
|
146
|
+
}
|
147
|
+
return validated;
|
148
|
+
}
|
149
|
+
/**
|
150
|
+
* Validates and sanitizes the $filter query parameter with comprehensive security checks
|
151
|
+
* Prevents injection attacks, validates operators and property references
|
152
|
+
* @param value - OData filter expression
|
153
|
+
* @returns Sanitized filter string in CDS syntax
|
154
|
+
* @throws Error if filter contains forbidden patterns or invalid syntax
|
155
|
+
*/
|
156
|
+
validateFilter(value) {
|
157
|
+
const input = value?.replace("filter=", "");
|
158
|
+
if (!input || input.trim().length === 0) {
|
159
|
+
throw new Error("Filter parameter cannot be empty");
|
160
|
+
}
|
161
|
+
const decoded = decodeURIComponent(input);
|
162
|
+
const validated = exports.ODataQueryValidationSchemas.filter.parse(decoded);
|
163
|
+
// Check for forbidden patterns
|
164
|
+
for (const pattern of FORBIDDEN_PATTERNS) {
|
165
|
+
if (pattern.test(validated)) {
|
166
|
+
logger_1.LOGGER.warn(`Potentially malicious filter pattern detected: ${pattern.source}`);
|
167
|
+
throw new Error("Filter contains forbidden patterns");
|
168
|
+
}
|
169
|
+
}
|
170
|
+
// Parse and validate filter structure
|
171
|
+
return this.parseAndValidateFilter(validated);
|
172
|
+
}
|
173
|
+
/**
|
174
|
+
* Parses OData filter expression and validates all components
|
175
|
+
* @param filter - Decoded and pre-validated filter string
|
176
|
+
* @returns CDS-compatible filter expression
|
177
|
+
*/
|
178
|
+
parseAndValidateFilter(filter) {
|
179
|
+
// Tokenize the filter expression
|
180
|
+
const tokens = this.tokenizeFilter(filter);
|
181
|
+
// Validate tokens
|
182
|
+
this.validateFilterTokens(tokens);
|
183
|
+
// Convert OData operators to CDS syntax
|
184
|
+
return this.convertToCdsFilter(tokens);
|
185
|
+
}
|
186
|
+
/**
|
187
|
+
* Tokenizes filter expression into structured components for validation
|
188
|
+
* @param filter - Filter string to tokenize
|
189
|
+
* @returns Array of typed tokens representing the filter structure
|
190
|
+
*/
|
191
|
+
tokenizeFilter(filter) {
|
192
|
+
const tokens = [];
|
193
|
+
// Enhanced tokenizer with OData operator support - literals first to avoid misclassification
|
194
|
+
const tokenRegex = /(\b(?:eq|ne|gt|ge|lt|le|contains|startswith|endswith)\b)|('[^']*'|"[^"]*"|\d+(?:\.\d+)?)|([<>=!]+)|(\b(?:and|or|not)\b)|(\(|\))|(\w+)/gi;
|
195
|
+
let match;
|
196
|
+
while ((match = tokenRegex.exec(filter)) !== null) {
|
197
|
+
const token = match[0];
|
198
|
+
if (match[1]) {
|
199
|
+
// OData operators
|
200
|
+
tokens.push({ type: "operator", value: token.toLowerCase() });
|
201
|
+
}
|
202
|
+
else if (match[2]) {
|
203
|
+
// Literal values (strings, numbers) - prioritized to avoid misclassification
|
204
|
+
tokens.push({ type: "literal", value: token });
|
205
|
+
}
|
206
|
+
else if (match[3]) {
|
207
|
+
// Comparison operators
|
208
|
+
tokens.push({ type: "operator", value: token });
|
209
|
+
}
|
210
|
+
else if (match[4]) {
|
211
|
+
// Logical operators
|
212
|
+
tokens.push({ type: "logical", value: token.toLowerCase() });
|
213
|
+
}
|
214
|
+
else if (match[5]) {
|
215
|
+
// Parentheses
|
216
|
+
tokens.push({ type: "paren", value: token });
|
217
|
+
}
|
218
|
+
else if (match[6]) {
|
219
|
+
// Property or function names
|
220
|
+
tokens.push({ type: "property", value: token });
|
221
|
+
}
|
222
|
+
}
|
223
|
+
return tokens;
|
224
|
+
}
|
225
|
+
/**
|
226
|
+
* Validates all filter tokens against allowed properties and operators
|
227
|
+
* @param tokens - Array of parsed filter tokens to validate
|
228
|
+
* @throws Error if any token contains invalid properties or operators
|
229
|
+
*/
|
230
|
+
validateFilterTokens(tokens) {
|
231
|
+
for (const token of tokens) {
|
232
|
+
switch (token.type) {
|
233
|
+
case "property":
|
234
|
+
// Check if it's a known OData function
|
235
|
+
if (!ALLOWED_OPERATORS.has(token.value.toLowerCase()) &&
|
236
|
+
!this.allowedProperties.has(token.value)) {
|
237
|
+
throw new Error(`Invalid property in filter: ${token.value}. Allowed properties: ${Array.from(this.allowedProperties).join(", ")}`);
|
238
|
+
}
|
239
|
+
break;
|
240
|
+
case "operator":
|
241
|
+
// Validate operator format (both symbols and OData operators)
|
242
|
+
if (!/^[<>=!]+$/.test(token.value) &&
|
243
|
+
!ALLOWED_OPERATORS.has(token.value.toLowerCase())) {
|
244
|
+
throw new Error(`Invalid operator: ${token.value}`);
|
245
|
+
}
|
246
|
+
break;
|
247
|
+
case "logical":
|
248
|
+
if (!["and", "or", "not"].includes(token.value)) {
|
249
|
+
throw new Error(`Invalid logical operator: ${token.value}`);
|
250
|
+
}
|
251
|
+
break;
|
252
|
+
}
|
253
|
+
}
|
254
|
+
}
|
255
|
+
/**
|
256
|
+
* Converts validated OData filter tokens to CDS-compatible filter syntax
|
257
|
+
* @param tokens - Array of validated filter tokens
|
258
|
+
* @returns CDS filter expression string
|
259
|
+
*/
|
260
|
+
convertToCdsFilter(tokens) {
|
261
|
+
const cdsTokens = [];
|
262
|
+
for (const token of tokens) {
|
263
|
+
if (token.type === "operator") {
|
264
|
+
// Convert OData operators to CDS operators
|
265
|
+
switch (token.value.toLowerCase()) {
|
266
|
+
case "eq":
|
267
|
+
cdsTokens.push("=");
|
268
|
+
break;
|
269
|
+
case "ne":
|
270
|
+
cdsTokens.push("!=");
|
271
|
+
break;
|
272
|
+
case "gt":
|
273
|
+
cdsTokens.push(">");
|
274
|
+
break;
|
275
|
+
case "ge":
|
276
|
+
cdsTokens.push(">=");
|
277
|
+
break;
|
278
|
+
case "lt":
|
279
|
+
cdsTokens.push("<");
|
280
|
+
break;
|
281
|
+
case "le":
|
282
|
+
cdsTokens.push("<=");
|
283
|
+
break;
|
284
|
+
default:
|
285
|
+
cdsTokens.push(token.value);
|
286
|
+
break;
|
287
|
+
}
|
288
|
+
}
|
289
|
+
else {
|
290
|
+
cdsTokens.push(token.value);
|
291
|
+
continue;
|
292
|
+
}
|
293
|
+
}
|
294
|
+
return cdsTokens.join(" ");
|
295
|
+
}
|
296
|
+
}
|
297
|
+
exports.ODataQueryValidator = ODataQueryValidator;
|
298
|
+
/**
|
299
|
+
* Specialized error class for OData query parameter validation failures
|
300
|
+
* Provides additional context about which parameter and value caused the error
|
301
|
+
*/
|
302
|
+
class ODataValidationError extends Error {
|
303
|
+
parameter;
|
304
|
+
value;
|
305
|
+
/**
|
306
|
+
* Creates a new OData validation error
|
307
|
+
* @param message - Error description
|
308
|
+
* @param parameter - Name of the parameter that failed validation
|
309
|
+
* @param value - The invalid value that caused the error
|
310
|
+
*/
|
311
|
+
constructor(message, parameter, value) {
|
312
|
+
super(message);
|
313
|
+
this.parameter = parameter;
|
314
|
+
this.value = value;
|
315
|
+
this.name = "ODataValidationError";
|
316
|
+
}
|
317
|
+
}
|
318
|
+
exports.ODataValidationError = ODataValidationError;
|