Skip to content

Commit 76f45c4

Browse files
snicollbclozel
andcommitted
Add support for JSON assertions using JSON path
This commit moves JSON path AssertJ support from Spring Boot. See gh-21178 Co-authored-by: Brian Clozel <[email protected]>
1 parent 97ebc43 commit 76f45c4

File tree

5 files changed

+1103
-0
lines changed

5 files changed

+1103
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.test.json;
18+
19+
import java.lang.reflect.Array;
20+
import java.lang.reflect.Type;
21+
import java.util.List;
22+
import java.util.Map;
23+
24+
import org.assertj.core.api.AbstractBooleanAssert;
25+
import org.assertj.core.api.AbstractMapAssert;
26+
import org.assertj.core.api.AbstractObjectAssert;
27+
import org.assertj.core.api.AbstractStringAssert;
28+
import org.assertj.core.api.Assertions;
29+
import org.assertj.core.api.ObjectArrayAssert;
30+
import org.assertj.core.error.BasicErrorMessageFactory;
31+
import org.assertj.core.internal.Failures;
32+
33+
import org.springframework.core.ParameterizedTypeReference;
34+
import org.springframework.core.ResolvableType;
35+
import org.springframework.http.HttpInputMessage;
36+
import org.springframework.http.MediaType;
37+
import org.springframework.http.converter.GenericHttpMessageConverter;
38+
import org.springframework.lang.Nullable;
39+
import org.springframework.mock.http.MockHttpInputMessage;
40+
import org.springframework.mock.http.MockHttpOutputMessage;
41+
import org.springframework.util.ObjectUtils;
42+
import org.springframework.util.StringUtils;
43+
44+
/**
45+
* Base AssertJ {@link org.assertj.core.api.Assert assertions} that can be
46+
* applied to a JSON value. In JSON, values must be one of the following data
47+
* types:
48+
* <ul>
49+
* <li>a {@linkplain #asString() string}</li>
50+
* <li>a {@linkplain #asNumber() number}</li>
51+
* <li>a {@linkplain #asBoolean() boolean}</li>
52+
* <li>an {@linkplain #asArray() array}</li>
53+
* <li>an {@linkplain #asMap() object} (JSON object)</li>
54+
* <li>{@linkplain #isNull() null}</li>
55+
* </ul>
56+
* This base class offers direct access for each of those types as well as a
57+
* conversion methods based on an optional {@link GenericHttpMessageConverter}.
58+
*
59+
* @author Stephane Nicoll
60+
* @since 6.2
61+
* @param <SELF> the type of assertions
62+
*/
63+
public abstract class AbstractJsonValueAssert<SELF extends AbstractJsonValueAssert<SELF>>
64+
extends AbstractObjectAssert<SELF, Object> {
65+
66+
private final Failures failures = Failures.instance();
67+
68+
@Nullable
69+
private final GenericHttpMessageConverter<Object> httpMessageConverter;
70+
71+
72+
protected AbstractJsonValueAssert(@Nullable Object actual, Class<?> selfType,
73+
@Nullable GenericHttpMessageConverter<Object> httpMessageConverter) {
74+
super(actual, selfType);
75+
this.httpMessageConverter = httpMessageConverter;
76+
}
77+
78+
/**
79+
* Verify that the actual value is a non-{@code null} {@link String}
80+
* and return a new {@linkplain AbstractStringAssert assertion} object that
81+
* provides dedicated {@code String} assertions for it.
82+
*/
83+
@Override
84+
public AbstractStringAssert<?> asString() {
85+
return Assertions.assertThat(castTo(String.class, "a string"));
86+
}
87+
88+
/**
89+
* Verify that the actual value is a non-{@code null} {@link Number},
90+
* usually an {@link Integer} or {@link Double} and return a new
91+
* {@linkplain AbstractObjectAssert assertion} object for it.
92+
*/
93+
public AbstractObjectAssert<?, Number> asNumber() {
94+
return Assertions.assertThat(castTo(Number.class, "a number"));
95+
}
96+
97+
/**
98+
* Verify that the actual value is a non-{@code null} {@link Boolean}
99+
* and return a new {@linkplain AbstractBooleanAssert assertion} object
100+
* that provides dedicated {@code Boolean} assertions for it.
101+
*/
102+
public AbstractBooleanAssert<?> asBoolean() {
103+
return Assertions.assertThat(castTo(Boolean.class, "a boolean"));
104+
}
105+
106+
/**
107+
* Verify that the actual value is a non-{@code null} {@link Array}
108+
* and return a new {@linkplain ObjectArrayAssert assertion} object
109+
* that provides dedicated {@code Array} assertions for it.
110+
*/
111+
public ObjectArrayAssert<Object> asArray() {
112+
List<?> list = castTo(List.class, "an array");
113+
Object[] array = list.toArray(new Object[0]);
114+
return Assertions.assertThat(array);
115+
}
116+
117+
/**
118+
* Verify that the actual value is a non-{@code null} JSON object and
119+
* return a new {@linkplain AbstractMapAssert assertion} object that
120+
* provides dedicated assertions on individual elements of the
121+
* object. The returned map assertion object uses the attribute name as the
122+
* key, and the value can itself be any of the valid JSON values.
123+
*/
124+
@SuppressWarnings("unchecked")
125+
public AbstractMapAssert<?, Map<String, Object>, String, Object> asMap() {
126+
return Assertions.assertThat(castTo(Map.class, "a map"));
127+
}
128+
129+
private <T> T castTo(Class<T> expectedType, String description) {
130+
if (this.actual == null) {
131+
throw valueProcessingFailed("To be %s%n".formatted(description));
132+
}
133+
if (!expectedType.isInstance(this.actual)) {
134+
throw valueProcessingFailed("To be %s%nBut was:%n %s%n".formatted(description, this.actual.getClass().getName()));
135+
}
136+
return expectedType.cast(this.actual);
137+
}
138+
139+
/**
140+
* Verify that the actual value can be converted to an instance of the
141+
* given {@code target} and produce a new {@linkplain AbstractObjectAssert
142+
* assertion} object narrowed to that type.
143+
* @param target the {@linkplain Class type} to convert the actual value to
144+
*/
145+
public <T> AbstractObjectAssert<?, T> convertTo(Class<T> target) {
146+
isNotNull();
147+
T value = convertToTargetType(target);
148+
return Assertions.assertThat(value);
149+
}
150+
151+
/**
152+
* Verify that the actual value can be converted to an instance of the
153+
* given {@code target} and produce a new {@linkplain AbstractObjectAssert
154+
* assertion} object narrowed to that type.
155+
* @param target the {@linkplain ParameterizedTypeReference parameterized
156+
* type} to convert the actual value to
157+
*/
158+
public <T> AbstractObjectAssert<?, T> convertTo(ParameterizedTypeReference<T> target) {
159+
isNotNull();
160+
T value = convertToTargetType(target.getType());
161+
return Assertions.assertThat(value);
162+
}
163+
164+
/**
165+
* Verify that the actual value is empty, that is a {@code null} scalar
166+
* value or an empty list or map. Can also be used when the path is using a
167+
* filter operator to validate that it dit not match.
168+
*/
169+
public SELF isEmpty() {
170+
if (!ObjectUtils.isEmpty(this.actual)) {
171+
throw valueProcessingFailed("To be empty");
172+
}
173+
return this.myself;
174+
}
175+
176+
/**
177+
* Verify that the actual value is not empty, that is a non-{@code null}
178+
* scalar value or a non-empty list or map. Can also be used when the path is
179+
* using a filter operator to validate that it dit match at least one
180+
* element.
181+
*/
182+
public SELF isNotEmpty() {
183+
if (ObjectUtils.isEmpty(this.actual)) {
184+
throw valueProcessingFailed("To not be empty");
185+
}
186+
return this.myself;
187+
}
188+
189+
190+
@SuppressWarnings("unchecked")
191+
private <T> T convertToTargetType(Type targetType) {
192+
if (this.httpMessageConverter == null) {
193+
throw new IllegalStateException(
194+
"No JSON message converter available to convert %s".formatted(actualToString()));
195+
}
196+
try {
197+
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
198+
this.httpMessageConverter.write(this.actual, ResolvableType.forInstance(this.actual).getType(),
199+
MediaType.APPLICATION_JSON, outputMessage);
200+
return (T) this.httpMessageConverter.read(targetType, getClass(),
201+
fromHttpOutputMessage(outputMessage));
202+
}
203+
catch (Exception ex) {
204+
throw valueProcessingFailed("To convert successfully to:%n %s%nBut it failed:%n %s%n"
205+
.formatted(targetType.getTypeName(), ex.getMessage()));
206+
}
207+
}
208+
209+
private HttpInputMessage fromHttpOutputMessage(MockHttpOutputMessage message) {
210+
MockHttpInputMessage inputMessage = new MockHttpInputMessage(message.getBodyAsBytes());
211+
inputMessage.getHeaders().addAll(message.getHeaders());
212+
return inputMessage;
213+
}
214+
215+
protected String getExpectedErrorMessagePrefix() {
216+
return "Expected:";
217+
}
218+
219+
private AssertionError valueProcessingFailed(String errorMessage) {
220+
throw this.failures.failure(this.info, new ValueProcessingFailed(
221+
getExpectedErrorMessagePrefix(), actualToString(), errorMessage));
222+
}
223+
224+
private String actualToString() {
225+
return ObjectUtils.nullSafeToString(StringUtils.quoteIfString(this.actual));
226+
}
227+
228+
private static final class ValueProcessingFailed extends BasicErrorMessageFactory {
229+
230+
private ValueProcessingFailed(String prefix, String actualToString, String errorMessage) {
231+
super("%n%s%n %s%n%s".formatted(prefix, actualToString, errorMessage));
232+
}
233+
}
234+
235+
}

0 commit comments

Comments
 (0)