Skip to content

Commit 555a329

Browse files
author
Thorsten Schlathoelter
committed
fix(#1415): allow "null" retrieval from
- improve error message
1 parent b6e532a commit 555a329

File tree

8 files changed

+765
-129
lines changed

8 files changed

+765
-129
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.citrusframework.exceptions;
2+
3+
public class SegmentEvaluationException extends Exception {
4+
5+
private final String renderedObject;
6+
7+
public SegmentEvaluationException(String reason, String renderedObject) {
8+
super(reason);
9+
this.renderedObject = renderedObject;
10+
}
11+
12+
public String getRenderedObject() {
13+
return renderedObject;
14+
}
15+
}

core/citrus-api/src/main/java/org/citrusframework/variable/SegmentVariableExtractorRegistry.java

Lines changed: 140 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import org.citrusframework.context.TestContext;
2828
import org.citrusframework.exceptions.CitrusRuntimeException;
29+
import org.citrusframework.exceptions.SegmentEvaluationException;
2930
import org.citrusframework.spi.ResourcePathTypeResolver;
3031
import org.citrusframework.spi.TypeResolver;
3132
import org.citrusframework.util.ReflectionHelper;
@@ -53,7 +54,6 @@ public class SegmentVariableExtractorRegistry {
5354
* Resolves extractor from resource path lookup with given extractor resource name. Scans classpath for extractor meta information
5455
* with given name and returns instance of extractor. Returns optional instead of throwing exception when no extractor
5556
* could be found.
56-
* @return
5757
*/
5858
static Collection<SegmentVariableExtractor> lookup() {
5959
try {
@@ -69,16 +69,16 @@ static Collection<SegmentVariableExtractor> lookup() {
6969
/**
7070
* SegmentVariableExtractors to extract values from value representations of individual segments.
7171
*/
72-
private final List<SegmentVariableExtractor> segmentValueExtractors = new ArrayList<>(List.of(MapVariableExtractor.INSTANCE, ObjectFieldValueExtractor.INSTANCE));
72+
private final List<SegmentVariableExtractor> segmentValueExtractors = new ArrayList<>(List.of(
73+
MapVariableExtractor.INSTANCE, ObjectFieldValueExtractor.INSTANCE));
7374

7475
public SegmentVariableExtractorRegistry() {
7576
segmentValueExtractors.addAll(lookup());
7677
}
7778

7879
/**
7980
* Obtain the segment variable extractors managed by the registry
80-
*
81-
* @return
81+
8282
*/
8383
public List<SegmentVariableExtractor> getSegmentValueExtractors() {
8484
return segmentValueExtractors;
@@ -87,140 +87,203 @@ public List<SegmentVariableExtractor> getSegmentValueExtractors() {
8787
/**
8888
* Base class for segment variable extractors that ensures that an exception is thrown upon no match.
8989
*/
90-
public static abstract class AbstractSegmentVariableExtractor implements SegmentVariableExtractor {
90+
public abstract static class AbstractSegmentVariableExtractor implements SegmentVariableExtractor {
9191

9292
@Override
9393
public final Object extractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) {
94-
Object matchedValue = doExtractValue(testContext, object, matcher);
9594

96-
if (matchedValue == null) {
97-
handleMatchFailure(matcher);
95+
try {
96+
return doExtractValue(testContext, object, matcher);
97+
} catch (SegmentEvaluationException e) {
98+
throw createMatchFailureException(matcher, object, e);
9899
}
99-
100-
return matchedValue;
101100
}
102101

103102
/**
104-
* Handles a match failure by throwing a CitrusException with an appropriate message
105-
* @param matcher
103+
* Builds a {@link CitrusRuntimeException} describing why a variable/segment could not be resolved.
106104
*/
107-
private void handleMatchFailure(VariableExpressionSegmentMatcher matcher) {
108-
String exceptionMessage;
109-
if (matcher.getTotalSegmentCount() == 1) {
110-
exceptionMessage = String.format("Unknown variable '%s'" ,
111-
matcher.getVariableExpression());
112-
} else {
113-
if (matcher.getSegmentIndex() == 1) {
114-
exceptionMessage = String.format("Unknown variable for first segment '%s' " +
115-
"of variable expression '%s'",
116-
matcher.getSegmentExpression(), matcher.getVariableExpression());
117-
} else {
118-
exceptionMessage = String.format("Unknown segment-value for segment '%s' " +
119-
"of variable expression '%s'",
120-
matcher.getSegmentExpression(), matcher.getVariableExpression());
121-
}
105+
private static CitrusRuntimeException createMatchFailureException(VariableExpressionSegmentMatcher matcher, Object object, SegmentEvaluationException cause) {
106+
107+
String expr = nullSafe(matcher.getVariableExpression());
108+
String segment = nullSafe(matcher.getSegmentExpression());
109+
int idx = safeIndex(matcher.getSegmentIndex());
110+
int total = safeIndex(matcher.getTotalSegmentCount());
111+
112+
String objectType = (object == null) ? "null" : object.getClass().getName();
113+
114+
StringBuilder sb = new StringBuilder(256)
115+
.append("Unable to extract value using expression '").append(expr).append("'");
116+
117+
if (total > 1 && idx >= 1 && idx <= total) {
118+
sb.append(" — failed at segment '").append(segment)
119+
.append("' (").append(idx).append('/').append(total).append(')');
122120
}
123-
throw new CitrusRuntimeException(exceptionMessage);
121+
122+
if (cause != null) {
123+
sb.append(String.format("%nReason: %s",
124+
cause.getMessage() == null ? "" : cause.getMessage()
125+
));
126+
}
127+
128+
sb.append(String.format("%nFrom object (%s):%n%s", objectType, cause != null ? cause.getRenderedObject() : ""));
129+
130+
return new CitrusRuntimeException(sb.toString());
124131
}
125132

126-
protected abstract Object doExtractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher);
133+
protected abstract Object doExtractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) throws SegmentEvaluationException;
134+
135+
private static String nullSafe(String s) { return s == null ? "<null>" : s; }
136+
137+
private static int safeIndex(int i) { return Math.max(0, i); }
138+
}
139+
140+
141+
142+
/** Minimal, safe rendering used as fallback (truncate huge payloads). */
143+
protected static String renderObjectMinimal(Object object) {
144+
if (object == null) return "null";
145+
return String.valueOf(object);
127146
}
128147

129148
/**
130-
* Base class for extractors that can operate on indexed values.
149+
* Base class for extractors that support an optional [index] on the segment.
131150
*/
132-
public static abstract class IndexedSegmentVariableExtractor extends AbstractSegmentVariableExtractor {
151+
public abstract static class IndexedSegmentVariableExtractor extends AbstractSegmentVariableExtractor {
133152

134-
public final Object doExtractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) {
153+
@Override
154+
public final Object doExtractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher)
155+
throws SegmentEvaluationException {
135156

136157
Object extractedValue = doExtractIndexedValue(testContext, object, matcher);
137158

138159
if (matcher.getSegmentIndex() != -1) {
139-
extractedValue = getIndexedElement(matcher, extractedValue);
160+
extractedValue = getIndexedElement(object, matcher, extractedValue);
140161
}
141162
return extractedValue;
142163
}
143164

144165
/**
145-
* Get the index element from an indexed value.
146-
*
147-
* @param matcher
148-
* @param indexedValue
149-
* @return
166+
* Return the element at the given index from arrays or lists. Throw SegmentEvaluationException for errors.
150167
*/
151-
private Object getIndexedElement(VariableExpressionSegmentMatcher matcher, Object indexedValue) {
168+
private Object getIndexedElement(Object root, VariableExpressionSegmentMatcher matcher, Object indexedValue)
169+
throws SegmentEvaluationException {
170+
171+
int idx = matcher.getSegmentIndex();
172+
173+
if (indexedValue == null) {
174+
throw new SegmentEvaluationException(
175+
String.format("Cannot index into null for segment '%s' (index %d)",
176+
matcher.getSegmentExpression(), idx),
177+
renderObjectMinimal(root));
178+
}
179+
180+
// Java array
152181
if (indexedValue.getClass().isArray()) {
153-
return Array.get(indexedValue, matcher.getSegmentIndex());
154-
} else {
155-
throw new CitrusRuntimeException(
156-
String.format("Expected an instance of Array type. Cannot retrieve indexed property %s from %s ",
157-
matcher.getSegmentExpression(), indexedValue.getClass().getName()));
182+
int length = Array.getLength(indexedValue);
183+
if (idx < 0 || idx >= length) {
184+
throw new SegmentEvaluationException(
185+
String.format("Index %d out of bounds (array length %d) for segment '%s'",
186+
idx, length, matcher.getSegmentExpression()),
187+
renderObjectMinimal(root));
188+
}
189+
return Array.get(indexedValue, idx);
190+
}
191+
192+
// java.util.List
193+
if (indexedValue instanceof List<?> list) {
194+
int length = list.size();
195+
if (idx < 0 || idx >= length) {
196+
throw new SegmentEvaluationException(
197+
String.format("Index %d out of bounds (list size %d) for segment '%s'",
198+
idx, length, matcher.getSegmentExpression()),
199+
renderObjectMinimal(root));
200+
}
201+
return list.get(idx);
158202
}
203+
204+
// Unsupported type
205+
throw new SegmentEvaluationException(
206+
String.format("Expected array or List for indexed access, but was %s (segment '%s')",
207+
indexedValue.getClass().getName(), matcher.getSegmentExpression()),
208+
renderObjectMinimal(root));
159209
}
160210

161-
/**
162-
* Extract the indexed value from the object
163-
*
164-
* @param object
165-
* @param matcher
166-
* @return
167-
*/
168-
protected abstract Object doExtractIndexedValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher);
211+
/** Implement in subclasses: extract the (possibly indexed) container value to index into. */
212+
protected abstract Object doExtractIndexedValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher)
213+
throws SegmentEvaluationException;
169214
}
170215

171216
/**
172-
* SegmentVariableExtractor that accesses the segment value by a {@link Field} of the parentObject
217+
* Extracts a segment via a declared field on the parent object.
173218
*/
174219
public static class ObjectFieldValueExtractor extends IndexedSegmentVariableExtractor {
175220

176-
public static ObjectFieldValueExtractor INSTANCE = new ObjectFieldValueExtractor();
177-
178-
private ObjectFieldValueExtractor() {
179-
// singleton
180-
}
221+
public static final ObjectFieldValueExtractor INSTANCE = new ObjectFieldValueExtractor();
222+
private ObjectFieldValueExtractor() {}
181223

182224
@Override
183-
protected Object doExtractIndexedValue(TestContext testContext, Object parentObject, VariableExpressionSegmentMatcher matcher) {
184-
Field field = ReflectionHelper.findField(parentObject.getClass(), matcher.getSegmentExpression());
185-
if (field == null) {
186-
throw new CitrusRuntimeException(String.format("Failed to get variable - unknown field '%s' on type %s",
187-
matcher.getSegmentExpression(), parentObject.getClass().getName()));
225+
protected Object doExtractIndexedValue(TestContext testContext, Object parentObject, VariableExpressionSegmentMatcher matcher)
226+
throws SegmentEvaluationException {
227+
try {
228+
Field field = ReflectionHelper.findField(parentObject.getClass(), matcher.getSegmentExpression());
229+
if (field == null) {
230+
throw new SegmentEvaluationException(
231+
String.format("Unknown field '%s' on type %s",
232+
matcher.getSegmentExpression(), parentObject.getClass().getName()),
233+
renderObjectMinimal(parentObject));
234+
}
235+
return ReflectionHelper.getField(field, parentObject);
236+
} catch (SegmentEvaluationException see) {
237+
throw see; // rethrow as-is
238+
} catch (Exception ex) {
239+
throw new SegmentEvaluationException(
240+
String.format("Failed to access field '%s' on type %s: %s",
241+
matcher.getSegmentExpression(), parentObject.getClass().getName(), ex.getMessage()),
242+
renderObjectMinimal(parentObject));
188243
}
189-
190-
return ReflectionHelper.getField(field, parentObject);
191244
}
192245

193246
@Override
194247
public boolean canExtract(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) {
248+
// Objects except Strings (JSON/XML strings handled by dedicated extractors)
195249
return object != null && !(object instanceof String);
196250
}
197251
}
198252

199253
/**
200-
* SegmentVariableExtractor that accesses the segment value from a {@link Map}. The extractor uses the segment expression
201-
* as key into the map.
254+
* Extracts a segment via Map lookup using the segment expression as key.
202255
*/
203256
public static class MapVariableExtractor extends IndexedSegmentVariableExtractor {
204257

205-
public static MapVariableExtractor INSTANCE = new MapVariableExtractor();
206-
207-
private MapVariableExtractor() {
208-
// singleton
209-
}
258+
public static final MapVariableExtractor INSTANCE = new MapVariableExtractor();
259+
private MapVariableExtractor() {}
210260

211261
@Override
212-
protected Object doExtractIndexedValue(TestContext testContext, Object parentObject, VariableExpressionSegmentMatcher matcher) {
262+
protected Object doExtractIndexedValue(TestContext testContext, Object parentObject, VariableExpressionSegmentMatcher matcher)
263+
throws SegmentEvaluationException {
264+
265+
if (!(parentObject instanceof Map<?, ?> map)) {
266+
throw new SegmentEvaluationException(
267+
String.format("Expected Map for segment '%s' but was %s",
268+
matcher.getSegmentExpression(), parentObject == null ? "null" : parentObject.getClass().getName()),
269+
renderObjectMinimal(parentObject));
270+
}
213271

214-
Object matchedValue = null;
215-
if (parentObject instanceof Map<?, ?>) {
216-
matchedValue = ((Map<?, ?>) parentObject).get(matcher.getSegmentExpression());
272+
String key = matcher.getSegmentExpression();
273+
if (!map.containsKey(key)) {
274+
throw new SegmentEvaluationException(
275+
String.format("Unknown key '%s' in Map", key),
276+
renderObjectMinimal(parentObject));
217277
}
218-
return matchedValue;
278+
279+
// Value may legitimately be null—return it as-is.
280+
return map.get(key);
219281
}
220282

221283
@Override
222284
public boolean canExtract(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) {
223285
return object instanceof Map;
224286
}
225287
}
288+
226289
}

0 commit comments

Comments
 (0)