cap-mcp-guard 0.2.0 → 0.3.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.
package/lib/core/edm.js DELETED
@@ -1,241 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * A hand-rolled, regex-based reader for OData `$metadata` documents (EDMX/
5
- * CSDL XML, V2 or V4) — not a general-purpose XML/EDM implementation, just
6
- * enough structure (entity sets, entity types, navigation properties) to
7
- * resolve a URL path or a `$expand` nav-property name down to the entity
8
- * name cap-mcp-guard.yaml keys entities by. Good enough for the
9
- * well-formed $metadata documents SAP/CAP services actually emit.
10
- *
11
- * Pure — no I/O. Fetching `$metadata` over the network is the caller's
12
- * responsibility (see lib/adapters/odata.js's `metadataXml`/`edm` options);
13
- * keeping that out of this module means it stays synchronous and
14
- * dependency-free.
15
- */
16
-
17
- function matchAll(pattern, text) {
18
- const results = [];
19
- const re = new RegExp(pattern, 'g');
20
- let m;
21
- while ((m = re.exec(text))) results.push(m);
22
- return results;
23
- }
24
-
25
- function getAttr(tagAttrs, name) {
26
- const m = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`).exec(tagAttrs);
27
- return m ? m[1] : undefined;
28
- }
29
-
30
- function unwrapCollection(type) {
31
- const m = /^Collection\(([^)]+)\)$/.exec(type || '');
32
- return m ? { target: m[1], isCollection: true } : { target: type, isCollection: false };
33
- }
34
-
35
- function unqualifiedName(fqName) {
36
- if (!fqName) return fqName;
37
- const idx = fqName.lastIndexOf('.');
38
- return idx === -1 ? fqName : fqName.slice(idx + 1);
39
- }
40
-
41
- /**
42
- * Parses an EDMX/CSDL XML string into a minimal EDM model.
43
- *
44
- * @param {string} xml
45
- * @returns {{
46
- * entityTypes: Record<string, { navigationProperties: Record<string, { target: string, isCollection?: boolean }> }>,
47
- * entitySets: Record<string, string>,
48
- * entityTypeToSet: Record<string, string>
49
- * }}
50
- */
51
- function parseMetadata(xml) {
52
- const entityTypes = {};
53
- const entitySets = {};
54
- const entityTypeToSet = {};
55
-
56
- const schemaBlocks = matchAll('<Schema\\b([^>]*)>([\\s\\S]*?)</Schema>', xml);
57
-
58
- for (const schemaMatch of schemaBlocks) {
59
- const namespace = getAttr(schemaMatch[1], 'Namespace');
60
- const schemaBody = schemaMatch[2];
61
- if (!namespace) continue;
62
-
63
- // V2 only: NavigationProperty indirects through Relationship/ToRole to
64
- // an <Association>'s <End Role="..." Type="...">. V4 NavigationProperty
65
- // carries its target Type directly, no association needed.
66
- const associations = {};
67
- for (const assocMatch of matchAll('<Association\\b[^>]*Name="([^"]*)"[^>]*>([\\s\\S]*?)</Association>', schemaBody)) {
68
- const fqName = `${namespace}.${assocMatch[1]}`;
69
- const roles = {};
70
- for (const endMatch of matchAll('<End\\b([^>]*?)/?>', assocMatch[2])) {
71
- const role = getAttr(endMatch[1], 'Role');
72
- const type = getAttr(endMatch[1], 'Type');
73
- if (role && type) roles[role] = type;
74
- }
75
- associations[fqName] = roles;
76
- }
77
-
78
- for (const entityTypeMatch of matchAll('<EntityType\\b([^>]*)>([\\s\\S]*?)</EntityType>', schemaBody)) {
79
- const localName = getAttr(entityTypeMatch[1], 'Name');
80
- if (!localName) continue;
81
- const fqName = `${namespace}.${localName}`;
82
- const body = entityTypeMatch[2];
83
-
84
- const navigationProperties = {};
85
- for (const navMatch of matchAll('<NavigationProperty\\b([^>]*?)/?>', body)) {
86
- const attrs = navMatch[1];
87
- const navName = getAttr(attrs, 'Name');
88
- if (!navName) continue;
89
-
90
- const typeAttr = getAttr(attrs, 'Type');
91
- if (typeAttr) {
92
- navigationProperties[navName] = unwrapCollection(typeAttr);
93
- continue;
94
- }
95
-
96
- const relationship = getAttr(attrs, 'Relationship');
97
- const toRole = getAttr(attrs, 'ToRole');
98
- const target = relationship && toRole && associations[relationship] && associations[relationship][toRole];
99
- if (target) {
100
- navigationProperties[navName] = { target, isCollection: undefined };
101
- }
102
- }
103
-
104
- entityTypes[fqName] = { navigationProperties };
105
- }
106
-
107
- for (const setMatch of matchAll('<EntitySet\\b([^>]*?)/?>', schemaBody)) {
108
- const attrs = setMatch[1];
109
- const setName = getAttr(attrs, 'Name');
110
- const entityType = getAttr(attrs, 'EntityType');
111
- if (!setName || !entityType) continue;
112
-
113
- entitySets[setName] = entityType;
114
- if (!entityTypeToSet[entityType]) entityTypeToSet[entityType] = setName;
115
- }
116
- }
117
-
118
- return { entityTypes, entitySets, entityTypeToSet };
119
- }
120
-
121
- /** An entity set name when one maps to this type, else its bare local name. */
122
- function toDisplayName(edm, fqTypeName) {
123
- return (edm.entityTypeToSet && edm.entityTypeToSet[fqTypeName]) || unqualifiedName(fqTypeName);
124
- }
125
-
126
- /**
127
- * Resolves already-split, already-`(key)`-stripped, $-segment-free URL path
128
- * segments against a parsed EDM, walking navigation properties hop by hop
129
- * from the first segment that matches a known entity set (segments before
130
- * that — a service mount path like `odata/v4/browse` — are ignored, so
131
- * this works regardless of where the service is mounted).
132
- *
133
- * Falls back to the last path segment (matching lib/core/odata.js's
134
- * no-metadata resolveEntitySet() heuristic) when no segment matches a
135
- * known entity set, and to the raw segment name at any hop whose nav
136
- * property isn't described in the EDM — a `$metadata` document that
137
- * doesn't fully describe every segment shouldn't break resolution for the
138
- * segments it DOES know about.
139
- *
140
- * @param {object} edm see parseMetadata()
141
- * @param {string[]} segments
142
- * @returns {string|undefined}
143
- */
144
- function resolveEntityForPath(edm, segments) {
145
- if (!segments.length) return undefined;
146
-
147
- const startIndex = segments.findIndex((s) => Object.prototype.hasOwnProperty.call(edm.entitySets, s));
148
- if (startIndex === -1) return segments[segments.length - 1];
149
-
150
- let currentType = edm.entitySets[segments[startIndex]];
151
- let displayName = toDisplayName(edm, currentType);
152
-
153
- for (let i = startIndex + 1; i < segments.length; i++) {
154
- const entityType = currentType && edm.entityTypes[currentType];
155
- const nav = entityType && entityType.navigationProperties[segments[i]];
156
-
157
- if (!nav) {
158
- displayName = segments[i];
159
- currentType = undefined;
160
- continue;
161
- }
162
-
163
- currentType = nav.target;
164
- displayName = toDisplayName(edm, currentType);
165
- }
166
-
167
- return displayName;
168
- }
169
-
170
- /**
171
- * Resolves a `$expand`'ed navigation-property name to its target entity's
172
- * policy-config-friendly name, given the (already-resolved) entity name of
173
- * the row it hangs off of. Falls back to the raw nav-property name when
174
- * the parent entity or the nav property itself isn't described in the EDM.
175
- *
176
- * @param {object} edm
177
- * @param {string} parentEntityName as returned by resolveEntityForPath() or
178
- * a previous resolveExpandTarget() call
179
- * @param {string} navPropName
180
- * @returns {string}
181
- */
182
- function resolveExpandTarget(edm, parentEntityName, navPropName) {
183
- const parentType =
184
- edm.entitySets[parentEntityName] ||
185
- Object.keys(edm.entityTypes).find((fq) => toDisplayName(edm, fq) === parentEntityName);
186
-
187
- const entityType = parentType && edm.entityTypes[parentType];
188
- const nav = entityType && entityType.navigationProperties[navPropName];
189
-
190
- return nav ? toDisplayName(edm, nav.target) : navPropName;
191
- }
192
-
193
- /**
194
- * Parses an OData `$expand` query-option value into a tree of nav-property
195
- * names to recurse into — e.g. `"author,genre($expand=parent)"` becomes
196
- * `{ author: {}, genre: { parent: {} } }`. Only a nested `$expand=` option
197
- * is honored inside a parenthesized nav property; other nested options
198
- * (`$select`, `$filter`, ...) don't change which nested entities show up
199
- * in the response body, so they're ignored.
200
- *
201
- * @param {string} expandParam raw (already URL-decoded) $expand value
202
- * @returns {object} nested tree, `{}` for an empty/missing value
203
- */
204
- function parseExpandOption(expandParam) {
205
- const tree = {};
206
- if (!expandParam) return tree;
207
-
208
- const parts = [];
209
- let depth = 0;
210
- let start = 0;
211
- for (let i = 0; i < expandParam.length; i++) {
212
- const ch = expandParam[i];
213
- if (ch === '(') depth++;
214
- else if (ch === ')') depth--;
215
- else if (ch === ',' && depth === 0) {
216
- parts.push(expandParam.slice(start, i));
217
- start = i + 1;
218
- }
219
- }
220
- parts.push(expandParam.slice(start));
221
-
222
- for (const rawPart of parts) {
223
- const part = rawPart.trim();
224
- if (!part) continue;
225
-
226
- const parenIdx = part.indexOf('(');
227
- if (parenIdx === -1) {
228
- tree[part] = tree[part] || {};
229
- continue;
230
- }
231
-
232
- const navName = part.slice(0, parenIdx).trim();
233
- const inner = part.slice(parenIdx + 1, part.lastIndexOf(')'));
234
- const nestedExpandMatch = /\$expand=([^;]*)/.exec(inner);
235
- tree[navName] = parseExpandOption(nestedExpandMatch ? nestedExpandMatch[1] : '');
236
- }
237
-
238
- return tree;
239
- }
240
-
241
- module.exports = { parseMetadata, resolveEntityForPath, resolveExpandTarget, parseExpandOption };
@@ -1,161 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * A minimal reader/writer for the classic OData V2 `$batch` wire format:
5
- * a `multipart/mixed` body whose parts are either a single embedded HTTP
6
- * request/response (`Content-Type: application/http`) or a nested
7
- * `multipart/mixed` "changeset" grouping several write operations
8
- * atomically. Not a general-purpose MIME implementation — just enough
9
- * structure to zip a batch's requests to its responses by position and
10
- * mask each response's JSON body in place.
11
- *
12
- * Responses are re-serialized canonically (consistent CRLF line endings,
13
- * consistent header ordering per part) rather than byte-for-byte
14
- * preserved — that's syntactically equivalent multipart/mixed, which is
15
- * all any compliant OData client relies on; it does not reproduce the
16
- * exact original bytes.
17
- *
18
- * Pure — no I/O, no HTTP framework dependency.
19
- */
20
-
21
- function toText(value) {
22
- return Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
23
- }
24
-
25
- /**
26
- * @param {string} contentType a Content-Type header value
27
- * @returns {string|undefined} the `boundary` parameter, unquoted
28
- */
29
- function extractBoundary(contentType) {
30
- const m = /boundary=("?)([^;"]+)\1/i.exec(contentType || '');
31
- return m ? m[2] : undefined;
32
- }
33
-
34
- /** Splits a multipart body into its raw part strings, boundary markers stripped. */
35
- function splitMultipart(rawBody, boundary) {
36
- const text = toText(rawBody);
37
- const marker = `--${boundary}`;
38
- const segments = text.split(marker);
39
-
40
- const parts = [];
41
- for (let i = 1; i < segments.length; i++) {
42
- const segment = segments[i];
43
- if (segment.startsWith('--')) break; // the closing "--boundary--" marker
44
-
45
- const trimmed = segment.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
46
- if (trimmed.length) parts.push(trimmed);
47
- }
48
- return parts;
49
- }
50
-
51
- function splitHeadersAndBody(text) {
52
- const m = /\r?\n\r?\n/.exec(text);
53
- if (!m) return { headerBlock: text, body: '' };
54
- return { headerBlock: text.slice(0, m.index), body: text.slice(m.index + m[0].length) };
55
- }
56
-
57
- function parseHeaderBlock(headerBlock) {
58
- const headers = {};
59
- for (const line of headerBlock.split(/\r?\n/)) {
60
- const m = /^([^:]+):\s*(.*)$/.exec(line);
61
- if (m) headers[m[1].trim().toLowerCase()] = m[2].trim();
62
- }
63
- return headers;
64
- }
65
-
66
- /** One embedded `application/http` part: a request-line/status-line, headers, and a body. */
67
- function parseHttpEnvelope(text, { isResponse }) {
68
- const { headerBlock, body } = splitHeadersAndBody(text);
69
- const [firstLine, ...headerLines] = headerBlock.split(/\r?\n/);
70
-
71
- if (isResponse) {
72
- const m = /^HTTP\/[\d.]+\s+(\d+)\s*(.*)$/.exec((firstLine || '').trim());
73
- return {
74
- kind: 'single',
75
- status: m ? Number(m[1]) : 200,
76
- statusText: m ? m[2].trim() : 'OK',
77
- headerLines: headerLines.filter((l) => l.trim().length > 0),
78
- body
79
- };
80
- }
81
-
82
- const m = /^(\S+)\s+(\S+)\s+HTTP\/[\d.]+$/.exec((firstLine || '').trim());
83
- return {
84
- kind: 'single',
85
- method: m ? m[1] : undefined,
86
- url: m ? m[2] : undefined,
87
- headerLines: headerLines.filter((l) => l.trim().length > 0),
88
- body
89
- };
90
- }
91
-
92
- /** Parses one top-level (or changeset-nested) batch part, recursing into changesets. */
93
- function parseBatchPart(rawPart, { isResponse }) {
94
- const { headerBlock, body } = splitHeadersAndBody(rawPart);
95
- const headers = parseHeaderBlock(headerBlock);
96
- const contentType = headers['content-type'] || '';
97
-
98
- if (/multipart\/mixed/i.test(contentType)) {
99
- const nestedBoundary = extractBoundary(contentType);
100
- return {
101
- kind: 'changeset',
102
- boundary: nestedBoundary,
103
- parts: nestedBoundary ? splitMultipart(body, nestedBoundary).map((p) => parseBatchPart(p, { isResponse })) : []
104
- };
105
- }
106
-
107
- return parseHttpEnvelope(body, { isResponse });
108
- }
109
-
110
- /**
111
- * @param {string|Buffer} rawBody the $batch request body
112
- * @param {string} boundary from the request's Content-Type header
113
- * @returns {object[]} tree of `{ kind: 'single', method, url, ... } | { kind: 'changeset', boundary, parts }`
114
- */
115
- function parseBatchRequests(rawBody, boundary) {
116
- return splitMultipart(rawBody, boundary).map((p) => parseBatchPart(p, { isResponse: false }));
117
- }
118
-
119
- /**
120
- * @param {string|Buffer} rawBody the $batch response body
121
- * @param {string} boundary from the response's Content-Type header
122
- * @returns {object[]} tree of `{ kind: 'single', status, statusText, headerLines, body } | { kind: 'changeset', boundary, parts }`
123
- */
124
- function parseBatchResponses(rawBody, boundary) {
125
- return splitMultipart(rawBody, boundary).map((p) => parseBatchPart(p, { isResponse: true }));
126
- }
127
-
128
- function serializeEnvelope(envelope) {
129
- const startLine = `HTTP/1.1 ${envelope.status} ${envelope.statusText}`;
130
- const headerText = envelope.headerLines.map((l) => `${l}\r\n`).join('');
131
- return `${startLine}\r\n${headerText}\r\n${envelope.body}`;
132
- }
133
-
134
- /**
135
- * Re-serializes a (possibly mutated) response tree — see parseBatchResponses()
136
- * — back into a valid `multipart/mixed` body using `boundary`.
137
- *
138
- * @param {object[]} items as returned by parseBatchResponses()
139
- * @param {string} boundary
140
- * @returns {string}
141
- */
142
- function serializeBatchResponses(items, boundary) {
143
- let out = '';
144
-
145
- for (const item of items) {
146
- out += `--${boundary}\r\n`;
147
-
148
- if (item.kind === 'changeset') {
149
- out += `Content-Type: multipart/mixed; boundary=${item.boundary}\r\n\r\n`;
150
- out += serializeBatchResponses(item.parts, item.boundary);
151
- } else {
152
- out += `Content-Type: application/http\r\nContent-Transfer-Encoding: binary\r\n\r\n`;
153
- out += `${serializeEnvelope(item)}\r\n`;
154
- }
155
- }
156
-
157
- out += `--${boundary}--\r\n`;
158
- return out;
159
- }
160
-
161
- module.exports = { extractBoundary, splitMultipart, parseBatchRequests, parseBatchResponses, serializeBatchResponses };
package/lib/core/odata.js DELETED
@@ -1,178 +0,0 @@
1
- 'use strict';
2
-
3
- const { resolveEntityForPath, resolveExpandTarget } = require('./edm');
4
-
5
- const ODATA_SYSTEM_SEGMENTS = new Set(['$metadata', '$batch']);
6
-
7
- const METHOD_TO_OPERATION = {
8
- GET: 'READ',
9
- POST: 'CREATE',
10
- PUT: 'UPDATE',
11
- PATCH: 'UPDATE',
12
- MERGE: 'UPDATE',
13
- DELETE: 'DELETE'
14
- };
15
-
16
- /**
17
- * Resolves the OData entity-set (or navigation property) name a request
18
- * targets, from its URL path alone — no service metadata ($metadata/CSDL)
19
- * is consulted. Works for both OData V2 (`/sap/opu/odata/sap/ZGW_SRV/Products`)
20
- * and V4 (`/odata/v4/browse/Books(201)`) path conventions: it's the last
21
- * path segment, with any `(key)` predicate stripped.
22
- *
23
- * Returns undefined for OData system resources ($metadata, $batch) since
24
- * those don't map to a single configurable entity.
25
- *
26
- * Pure function, no HTTP framework dependency.
27
- *
28
- * @param {string} url request path or full URL (query string is ignored)
29
- * @returns {string|undefined}
30
- */
31
- function resolveEntitySet(url) {
32
- if (!url) return undefined;
33
-
34
- const pathOnly = url.split('?')[0];
35
- const segments = pathOnly.split('/').filter(Boolean);
36
- if (segments.length === 0) return undefined;
37
-
38
- const last = segments[segments.length - 1].replace(/\(.*$/, '');
39
- if (!last || ODATA_SYSTEM_SEGMENTS.has(last) || last.startsWith('$')) return undefined;
40
-
41
- return last;
42
- }
43
-
44
- /**
45
- * Maps an HTTP method onto the same CQN-style operation vocabulary the
46
- * guard already uses for CAP requests (READ/CREATE/UPDATE/DELETE, see
47
- * lib/adapters/cap.js's resolveOperation) — so one cap-mcp-guard.yaml
48
- * applies unchanged whether traffic arrives via CAP's before/after hooks
49
- * or raw OData HTTP.
50
- *
51
- * @param {string} method
52
- * @returns {string|undefined}
53
- */
54
- function resolveOperation(method) {
55
- return METHOD_TO_OPERATION[(method || '').toUpperCase()];
56
- }
57
-
58
- /**
59
- * @param {string} url
60
- * @returns {boolean} true for a $batch request (OData V2 and V4 both use
61
- * this convention). Its body is a multipart mix of sub-requests this
62
- * module does not decode, so per-entity masking does not reach inside it
63
- * — see README "Limitations".
64
- */
65
- function isBatchRequest(url) {
66
- if (!url) return false;
67
- const pathOnly = url.split('?')[0];
68
- return pathOnly.split('/').filter(Boolean).pop() === '$batch';
69
- }
70
-
71
- /**
72
- * Extracts the row objects an OData JSON response body carries, regardless
73
- * of whether it's a V2 collection (`{ d: { results: [...] } }`), a V2
74
- * single entity (`{ d: {...} }`), a V4 collection (`{ value: [...] }`), or
75
- * a V4 single entity (a bare entity object carrying `@odata.context`).
76
- *
77
- * The returned rows are the SAME object references found inside `body` —
78
- * mutating a returned row mutates the response body in place, mirroring
79
- * how CAP's `after` handlers mutate results in place (see
80
- * lib/core/interceptor.js's applyMask).
81
- *
82
- * @param {*} body parsed JSON response body
83
- * @returns {object[]} row objects (empty array if the shape is unrecognized)
84
- */
85
- function extractResultRows(body) {
86
- if (!body || typeof body !== 'object') return [];
87
-
88
- if (Array.isArray(body.value)) return body.value;
89
- if (body.d && Array.isArray(body.d.results)) return body.d.results;
90
- if (body.d && typeof body.d === 'object') return [body.d];
91
- if ('@odata.context' in body) return [body];
92
-
93
- return [];
94
- }
95
-
96
- /**
97
- * Resolves the FULL request path (every segment, not just the last) into
98
- * a policy-config-friendly entity name. Without `edm`, this is exactly
99
- * resolveEntitySet()'s single-last-segment heuristic — unchanged behavior.
100
- * With `edm` (see lib/core/edm.js's parseMetadata()), the whole path is
101
- * walked hop-by-hop through navigation properties, so a deep path like
102
- * `Books(201)/author` resolves to `author`'s real target entity instead
103
- * of the literal string `"author"`.
104
- *
105
- * @param {string} url
106
- * @param {object} [edm] see lib/core/edm.js parseMetadata()
107
- * @returns {string|undefined}
108
- */
109
- function resolveEntityPath(url, edm) {
110
- if (!edm) return resolveEntitySet(url);
111
- if (!url) return undefined;
112
-
113
- const pathOnly = url.split('?')[0];
114
- const segments = pathOnly
115
- .split('/')
116
- .filter(Boolean)
117
- .map((s) => s.replace(/\(.*$/, ''))
118
- .filter((s) => s && !ODATA_SYSTEM_SEGMENTS.has(s) && !s.startsWith('$'));
119
-
120
- return resolveEntityForPath(edm, segments);
121
- }
122
-
123
- function nestedRowsFor(row, navName) {
124
- const value = row && typeof row === 'object' ? row[navName] : undefined;
125
-
126
- if (Array.isArray(value)) return value;
127
- if (value && Array.isArray(value.results)) return value.results; // OData V2 expand shape
128
- if (value && typeof value === 'object' && !('__deferred' in value)) return [value];
129
-
130
- return [];
131
- }
132
-
133
- /**
134
- * Walks a parsed OData response body and groups every row it contains —
135
- * the top-level collection/entity plus every `$expand`'ed nested
136
- * entity/collection reachable through `expandTree` — by the entity name
137
- * masking rules should be looked up under. Each group's rows are the SAME
138
- * object references found inside `body` (see extractResultRows), so
139
- * masking a group's rows mutates the response body in place.
140
- *
141
- * Without an `edm`, a nested entity's name is just its nav-property name
142
- * as it appears in `$expand` — good enough when cap-mcp-guard.yaml happens
143
- * to key an entity by that same name, but not a real entity-set/type
144
- * resolution. Pass `edm` (see lib/core/edm.js) to resolve nav properties
145
- * to their real target entity instead.
146
- *
147
- * @param {*} body parsed JSON response body
148
- * @param {string} rootEntity entity name for the top-level rows
149
- * @param {object} [expandTree] see lib/core/edm.js parseExpandOption()
150
- * @param {object} [edm] see lib/core/edm.js parseMetadata()
151
- * @returns {{ entity: string, rows: object[] }[]}
152
- */
153
- function collectMaskableGroups(body, rootEntity, expandTree = {}, edm) {
154
- const groups = [];
155
-
156
- function walk(rows, entityName, tree) {
157
- if (rows.length) groups.push({ entity: entityName, rows });
158
-
159
- for (const [navName, subTree] of Object.entries(tree)) {
160
- const nestedEntity = edm ? resolveExpandTarget(edm, entityName, navName) : navName;
161
- const nestedRows = rows.flatMap((row) => nestedRowsFor(row, navName));
162
-
163
- walk(nestedRows, nestedEntity, subTree);
164
- }
165
- }
166
-
167
- walk(extractResultRows(body), rootEntity, expandTree);
168
- return groups;
169
- }
170
-
171
- module.exports = {
172
- resolveEntitySet,
173
- resolveOperation,
174
- isBatchRequest,
175
- extractResultRows,
176
- resolveEntityPath,
177
- collectMaskableGroups
178
- };