Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit caffb87

Browse files
fix: make respect jsonpath criteria compliant with rfc9535 (#2745)
1 parent 4a2be39 commit caffb87

27 files changed

Lines changed: 363 additions & 297 deletions

File tree

.changeset/honest-crabs-rhyme.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@redocly/respect-core": patch
3+
---
4+
5+
Made Respect's JSONPath criteria compliant with RFC 9535.

docs/@v2/rules/respect/no-criteria-xpath.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Example of criteria:
4343
successCriteria:
4444
- condition: $statusCode == 201
4545
- context: $response.body
46-
condition: $.name == 'Mermaid Treasure Identification and Analysis'
46+
condition: $[?@.name == "Mermaid Treasure Identification and Analysis"]
4747
type:
4848
type: jsonpath
4949
version: draft-goessner-dispatch-jsonpath-00

packages/core/src/rules/arazzo/__tests__/criteria-unique.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ describe('Arazzo criteria-unique', () => {
4242
- condition: $statusCode == 200
4343
- condition: $statusCode == 200
4444
- context: $response.body
45-
condition: $.name == 'Mermaid Treasure Identification and Analysis'
45+
condition: $[?@.name == "Mermaid Treasure Identification and Analysis"]
4646
type: jsonpath
4747
- context: $response.body
48-
condition: $.name == 'Mermaid Treasure Identification and Analysis'
48+
condition: $[?@.name == "Mermaid Treasure Identification and Analysis"]
4949
type: jsonpath
5050
onSuccess:
5151
- name: 'onSuccessActionName'

packages/core/src/rules/arazzo/__tests__/no-criteria-xpath.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe('Arazzo no-criteria-xpath', () => {
4141
successCriteria:
4242
- condition: $statusCode == 201
4343
- context: $response.body
44-
condition: $.name == 'Mermaid Treasure Identification and Analysis'
44+
condition: $[?@.name == "Mermaid Treasure Identification and Analysis"]
4545
type:
4646
type: jsonpath
4747
version: draft-goessner-dispatch-jsonpath-00

packages/core/src/rules/arazzo/__tests__/outputs-defined.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ describe('Arazzo outputs-defined', () => {
183183
criteria:
184184
- condition: $statusCode == $steps.get-museum-hours.outputs.broken-in-simple-failure-criteria
185185
- context: $response.body
186-
condition: $.name == $steps.get-museum-hours.outputs.broken-in-jsonpath-failure-criteria
186+
condition: $[?@.name == $steps.get-museum-hours.outputs.broken-in-jsonpath-failure-criteria]
187187
type: jsonpath
188188
- context: $response.body
189189
condition: $.name == $steps.get-museum-hours.outputs.broken-in-xpath-failure-criteria
@@ -194,15 +194,15 @@ describe('Arazzo outputs-defined', () => {
194194
criteria:
195195
- condition: $statusCode == $steps.get-museum-hours.outputs.broken-in-simple-success-criteria
196196
- context: $response.body
197-
condition: $.name == $steps.get-museum-hours.outputs.broken-in-jsonpath-success-criteria
197+
condition: $[?@.name == $steps.get-museum-hours.outputs.broken-in-jsonpath-success-criteria]
198198
type: jsonpath
199199
- context: $response.body
200200
condition: $.name == $steps.get-museum-hours.outputs.broken-in-xpath-success-criteria
201201
type: xpath
202202
successCriteria:
203203
- condition: $statusCode == $steps.get-museum-hours.outputs.broken-in-simple-success-criteria
204204
- context: $response.body
205-
condition: $.name == $steps.get-museum-hours.outputs.broken-in-jsonpath-success-criteria
205+
condition: $[?@.name == $steps.get-museum-hours.outputs.broken-in-jsonpath-success-criteria]
206206
type: jsonpath
207207
- context: $response.body
208208
condition: $.name == $steps.get-museum-hours.outputs.broken-in-xpath-success-criteria

packages/core/src/rules/arazzo/__tests__/requestBody-replacements-unique.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ describe('Arazzo requestBody-replacements-unique', () => {
4646
successCriteria:
4747
- condition: $statusCode == 201
4848
- context: $response.body
49-
condition: $.name == 'Mermaid Treasure Identification and Analysis'
49+
condition: $[?@.name == "Mermaid Treasure Identification and Analysis"]
5050
type: jsonpath
5151
outputs:
5252
createdEventId: $response.body.eventId

packages/respect-core/src/modules/__tests__/flow-runner/success-criteria/check-success-criteria.test.ts

Lines changed: 276 additions & 65 deletions
Large diffs are not rendered by default.

packages/respect-core/src/modules/flow-runner/success-criteria/check-success-criteria.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,12 @@ export function checkCriteria({
8383

8484
checks.push({
8585
name: CHECKS.SUCCESS_CRITERIA_CHECK,
86-
passed: evaluateJSONPathCondition(condition, data),
86+
passed: evaluateJSONPathCondition({
87+
condition,
88+
data,
89+
context: criteriaContext,
90+
logger: ctx.options.logger,
91+
}),
8792
message: `Checking jsonpath criteria: ${condition}`,
8893
severity: ctx.severity['SUCCESS_CRITERIA_CHECK'],
8994
condition: condition,
Lines changed: 42 additions & 197 deletions
Original file line numberDiff line numberDiff line change
@@ -1,211 +1,56 @@
1+
import { isPlainObject, type LoggerInterface } from '@redocly/openapi-core';
12
import { query, type JsonValue } from 'jsonpath-rfc9535';
23

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 {
423
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 {
1027
return false;
1128
}
1229
}
1330

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);
15943
});
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';
17544
}
17645

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+
}
19850

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)) {
20352
return JSON.stringify(value);
204-
});
205-
206-
return convertedCondition;
207-
}
53+
}
20854

209-
function transformHyphensToUnderscores(path: string): string {
210-
return path.replace(/\.([a-zA-Z0-9_-]+)/g, (_, prop) => '.' + prop.replace(/-/g, '_'));
55+
return String(value);
21156
}

resources/museum-api.arazzo.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ workflows:
6767
successCriteria:
6868
- condition: $statusCode == 201
6969
- context: $response.body
70-
condition: $.name == 'Mermaid Treasure Identification and Analysis'
70+
condition: "$[?@.name == 'Mermaid Treasure Identification and Analysis']"
7171
type: jsonpath
7272
outputs:
7373
createdEventId: $response.body#/eventId
@@ -104,7 +104,7 @@ workflows:
104104
successCriteria:
105105
- condition: $statusCode == 200
106106
- context: $response.body
107-
condition: $.name == 'Orca Identification and Analysis'
107+
condition: "$[?@.name == 'Orca Identification and Analysis']"
108108
type:
109109
type: jsonpath
110110
version: draft-goessner-dispatch-jsonpath-00

0 commit comments

Comments
 (0)