|
| 1 | +import { isPlainObject, type LoggerInterface } from '@redocly/openapi-core'; |
1 | 2 | import { query, type JsonValue } from 'jsonpath-rfc9535'; |
2 | 3 |
|
3 | | -export function evaluateJSONPathCondition(condition: string, context: JsonValue) { |
| 4 | +import type { RuntimeExpressionContext } from '../../../types.js'; |
| 5 | +import { evaluateRuntimeExpression } from '../../runtime-expressions/index.js'; |
| 6 | + |
| 7 | +// Matches Arazzo embedded runtime expressions of the form `{$...}`, which must be |
| 8 | +// resolved first to construct the final JSONPath expression. Nested braces are not |
| 9 | +// supported, which aligns with Arazzo runtime-expression syntax. |
| 10 | +const EMBEDDED_EXPRESSION_REGEX = /\{(\$[^{}]+)\}/g; |
| 11 | + |
| 12 | +export function evaluateJSONPathCondition({ |
| 13 | + condition, |
| 14 | + data, |
| 15 | + context, |
| 16 | + logger, |
| 17 | +}: { |
| 18 | + condition: string; |
| 19 | + data: JsonValue; |
| 20 | + context: RuntimeExpressionContext; |
| 21 | + logger: LoggerInterface; |
| 22 | +}): boolean { |
4 | 23 | try { |
5 | | - const resolvedCondition = parseExpressions(condition, context); |
6 | | - const evaluateFn = new Function(`return ${resolvedCondition};`); |
7 | | - |
8 | | - return !!evaluateFn(); |
9 | | - } catch (error) { |
| 24 | + const resolvedCondition = resolveEmbeddedExpressions(condition, context, logger); |
| 25 | + return query([data], resolvedCondition).length > 0; |
| 26 | + } catch { |
10 | 27 | return false; |
11 | 28 | } |
12 | 29 | } |
13 | 30 |
|
14 | | -function parseExpressions(condition: string, context: JsonValue): string { |
15 | | - const expressionsParts: Array<string> = []; |
16 | | - |
17 | | - let i = 0; |
18 | | - let expressionElements = ''; |
19 | | - |
20 | | - while (i < condition.length) { |
21 | | - if (condition[i] === '$') { |
22 | | - if (expressionElements.length > 0) { |
23 | | - expressionsParts.push(expressionElements); |
24 | | - expressionElements = ''; |
25 | | - } |
26 | | - const start = i; |
27 | | - const expression = parseSingleJSONPath(condition, i); |
28 | | - |
29 | | - if (expression.length > 1) { |
30 | | - const evaluatedExpression = evaluateJSONPathExpression(expression, context); |
31 | | - |
32 | | - expressionsParts.push(evaluatedExpression); |
33 | | - } |
34 | | - i = start + expression.length; |
35 | | - } else { |
36 | | - expressionElements += condition[i]; |
37 | | - i++; |
38 | | - } |
39 | | - } |
40 | | - |
41 | | - // Push any remaining content after the while loop |
42 | | - if (expressionElements.length > 0) { |
43 | | - expressionsParts.push(expressionElements); |
44 | | - } |
45 | | - |
46 | | - return expressionsParts.join(''); |
47 | | -} |
48 | | - |
49 | | -function parseSingleJSONPath(condition: string, startIndex: number): string { |
50 | | - let jsonpath = '$'; |
51 | | - let bracketDepth = 0; |
52 | | - let inQuotes = false; |
53 | | - let quoteChar = ''; |
54 | | - let inFilter = false; |
55 | | - let filterDepth = 0; |
56 | | - let i = startIndex + 1; // Skip the '$' |
57 | | - |
58 | | - while (i < condition.length) { |
59 | | - const char = condition[i]; |
60 | | - |
61 | | - if ((char === '"' || char === "'") && !inQuotes) { |
62 | | - inQuotes = true; |
63 | | - quoteChar = char; |
64 | | - jsonpath += char; |
65 | | - i++; |
66 | | - continue; |
67 | | - } |
68 | | - |
69 | | - if (char === quoteChar && inQuotes) { |
70 | | - inQuotes = false; |
71 | | - jsonpath += char; |
72 | | - i++; |
73 | | - continue; |
74 | | - } |
75 | | - |
76 | | - if (inQuotes) { |
77 | | - jsonpath += char; |
78 | | - i++; |
79 | | - continue; |
80 | | - } |
81 | | - |
82 | | - if (char === '[') { |
83 | | - bracketDepth++; |
84 | | - if (i + 1 < condition.length && condition[i + 1] === '?') { |
85 | | - inFilter = true; |
86 | | - filterDepth = bracketDepth; |
87 | | - } |
88 | | - jsonpath += char; |
89 | | - i++; |
90 | | - continue; |
91 | | - } |
92 | | - |
93 | | - if (char === ']') { |
94 | | - bracketDepth--; |
95 | | - if (inFilter && bracketDepth < filterDepth) { |
96 | | - inFilter = false; |
97 | | - } |
98 | | - jsonpath += char; |
99 | | - i++; |
100 | | - continue; |
101 | | - } |
102 | | - |
103 | | - // Stop at logical operators, comparison operators, or whitespace (outside of filters) |
104 | | - if (!inFilter && (/\s/.test(char) || /[<>=!&|,)]/.test(char))) { |
105 | | - break; |
106 | | - } |
107 | | - |
108 | | - jsonpath += char; |
109 | | - i++; |
110 | | - } |
111 | | - |
112 | | - return jsonpath; |
113 | | -} |
114 | | - |
115 | | -function evaluateJSONPathExpression(expression: string, context: JsonValue): string { |
116 | | - // Handle legacy .length suffix for backward compatibility that is not a valid RFC 9535 expression |
117 | | - if (expression.endsWith('.length')) { |
118 | | - const basePath = expression.slice(0, -'.length'.length); |
119 | | - const normalizedPath = transformHyphensToUnderscores(basePath); |
120 | | - const result = query(context, normalizedPath); |
121 | | - const value = result[0] ?? null; |
122 | | - return Array.isArray(value) ? String(value.length) : '0'; |
123 | | - } |
124 | | - |
125 | | - if (expression.includes('[?(') && expression.includes(')]')) { |
126 | | - return handleFilterExpression(expression, context); |
127 | | - } |
128 | | - |
129 | | - const normalizedPath = transformHyphensToUnderscores(expression); |
130 | | - const result = query(context, normalizedPath); |
131 | | - return JSON.stringify(result[0]); |
132 | | -} |
133 | | - |
134 | | -function handleFilterExpression(expression: string, context: JsonValue): string { |
135 | | - const filterMatch = expression.match(/\[\?\((.*)\)\]/); |
136 | | - if (!filterMatch) return 'false'; |
137 | | - |
138 | | - const filterCondition = filterMatch[1]; |
139 | | - const basePath = expression.substring(0, expression.indexOf('[?(')); |
140 | | - const normalizedBasePath = transformHyphensToUnderscores(basePath); |
141 | | - const jsonpathResult = query(context, normalizedBasePath); |
142 | | - |
143 | | - // Flatten the result in case JSONPath returns nested arrays |
144 | | - const arrayToFilter = Array.isArray(jsonpathResult) ? jsonpathResult.flat() : jsonpathResult; |
145 | | - |
146 | | - if (!Array.isArray(arrayToFilter)) { |
147 | | - return 'false'; |
148 | | - } |
149 | | - |
150 | | - const filteredArray = arrayToFilter.filter((item: unknown) => { |
151 | | - const convertedCondition = processFilterCondition(filterCondition, item); |
152 | | - |
153 | | - try { |
154 | | - const safeEval = new Function('item', `return ${convertedCondition};`); |
155 | | - return !!safeEval(item); |
156 | | - } catch { |
157 | | - return false; |
158 | | - } |
| 31 | +// Per the Arazzo spec, expressions can be embedded into string values by surrounding |
| 32 | +// the expression with `{}` curly braces. Any embedded `{$...}` expression in a JSONPath |
| 33 | +// condition MUST be evaluated first to construct the JSONPath expression before it is |
| 34 | +// evaluated against the data. |
| 35 | +function resolveEmbeddedExpressions( |
| 36 | + condition: string, |
| 37 | + context: RuntimeExpressionContext, |
| 38 | + logger: LoggerInterface |
| 39 | +): string { |
| 40 | + return condition.replace(EMBEDDED_EXPRESSION_REGEX, (match, expression) => { |
| 41 | + const value = evaluateRuntimeExpression(expression, context, logger); |
| 42 | + return formatJsonPathLiteral(value, match); |
159 | 43 | }); |
160 | | - |
161 | | - const afterFilter = expression.substring(expression.indexOf(')]') + 2); |
162 | | - |
163 | | - if (afterFilter.startsWith('.')) { |
164 | | - const propertyMatch = afterFilter.match(/\.([a-zA-Z0-9_-]+)/); |
165 | | - if (propertyMatch) { |
166 | | - const propertyName = propertyMatch[1].replace(/-/g, '_'); |
167 | | - const propertyValues = filteredArray.map( |
168 | | - (item: unknown) => (item as Record<string, unknown>)[propertyName] |
169 | | - ); |
170 | | - return JSON.stringify(propertyValues); |
171 | | - } |
172 | | - } |
173 | | - |
174 | | - return filteredArray.length > 0 ? JSON.stringify(filteredArray) : 'false'; |
175 | 44 | } |
176 | 45 |
|
177 | | -function processFilterCondition(filterCondition: string, item: unknown): string { |
178 | | - let convertedCondition = filterCondition; |
179 | | - |
180 | | - // Handle @.property.match(/pattern/) expressions |
181 | | - convertedCondition = convertedCondition.replace( |
182 | | - /@\.([a-zA-Z0-9_-]+)\.match\(([^)]+)\)/g, |
183 | | - (_, prop: string, pattern: string) => { |
184 | | - const normalizedProp = prop.replace(/-/g, '_'); |
185 | | - const value = (item as Record<string, unknown>)[normalizedProp]; |
186 | | - if (typeof value !== 'string') return 'false'; |
187 | | - |
188 | | - try { |
189 | | - let cleanPattern = pattern.replace(/^["']|["']$/g, ''); // Remove quotes |
190 | | - cleanPattern = cleanPattern.replace(/^\/|\/$/g, ''); // Remove leading/trailing slashes |
191 | | - const regex = new RegExp(cleanPattern); |
192 | | - return String(regex.test(value)); |
193 | | - } catch { |
194 | | - return 'false'; |
195 | | - } |
196 | | - } |
197 | | - ); |
| 46 | +function formatJsonPathLiteral(value: unknown, fallback: string): string { |
| 47 | + if (value === undefined) { |
| 48 | + return fallback; |
| 49 | + } |
198 | 50 |
|
199 | | - // Handle @.property expressions (simple property access) |
200 | | - convertedCondition = convertedCondition.replace(/@\.([a-zA-Z0-9_-]+)/g, (_, prop: string) => { |
201 | | - const normalizedProp = prop.replace(/-/g, '_'); |
202 | | - const value = (item as Record<string, unknown>)[normalizedProp]; |
| 51 | + if (typeof value === 'string' || Array.isArray(value) || isPlainObject(value)) { |
203 | 52 | return JSON.stringify(value); |
204 | | - }); |
205 | | - |
206 | | - return convertedCondition; |
207 | | -} |
| 53 | + } |
208 | 54 |
|
209 | | -function transformHyphensToUnderscores(path: string): string { |
210 | | - return path.replace(/\.([a-zA-Z0-9_-]+)/g, (_, prop) => '.' + prop.replace(/-/g, '_')); |
| 55 | + return String(value); |
211 | 56 | } |
0 commit comments