java - Spring WebClient 不读取超媒体链接
问题描述
我正在使用 Spring 的 WebClient 从具有超媒体链接和 OAuth2 身份验证的外部 API 读取数据。访问 API 时,JSON 数据被正确地转换为模型对象,但如果模型对象扩展 Spring HATEOAS RepresentationModel 或当模型对象扩展 EntityModel 时,提供的 HAL 链接要么被忽略,要么给出 NullPointerException。我怀疑 hypermediaWebClientCustomizer 存在问题,但目前无法解决。
我尝试在测试用例中使用 Traverson 客户端读取 JSON。如果我将相对 URI 替换为绝对 URI,并将 application/json 标头替换为 application/hal+json 标头,那基本上是可行的。我会继续使用 Traverson,但除了这两个问题之外,Traverson 还需要一个 RestTemplate(在这种情况下为 OAuth2RestTemplate),它在我们的 Spring 版本中不再可用。
如果配置有问题或其他可能出现问题的任何想法?
这是我的配置:
依赖项(部分)
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.5.RELEASE</version>
<relativePath/>
</parent>
[...]
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
[...]
<!-- swagger dependencies -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>3.0.0</version>
</dependency>
[...]
</dependencies>
应用程序配置
@SpringBootApplication
@EnableScheduling
@EnableHypermediaSupport(type = EnableHypermediaSupport.HypermediaType.HAL)
public class MyApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(MyApplication.class);
}
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
网络客户端配置
@Configuration
@Slf4j
public class WebClientConfig {
private static final String REGISTRATION_ID = "myapi";
@Bean
ReactiveClientRegistrationRepository getRegistration(
@Value("${spring.security.oauth2.client.provider.myapi.token-uri}") String tokenUri,
@Value("${spring.security.oauth2.client.registration.myapi.client-id}") String clientId,
@Value("${spring.security.oauth2.client.registration.myapi.client-secret}") String clientSecret
) {
ClientRegistration registration = ClientRegistration
.withRegistrationId(REGISTRATION_ID)
.tokenUri(tokenUri)
.clientId(clientId)
.clientSecret(clientSecret)
//.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.authorizationGrantType(AuthorizationGrantType.PASSWORD)
.build();
return new InMemoryReactiveClientRegistrationRepository(registration);
}
@Bean
WebClientCustomizer hypermediaWebClientCustomizer(HypermediaWebClientConfigurer configurer) {
return webClientBuilder -> {
configurer.registerHypermediaTypes(webClientBuilder);
};
}
@Bean
public WebClient webClient(ReactiveClientRegistrationRepository clientRegistrations,
WebClient.Builder webClientBuilder){
InMemoryReactiveOAuth2AuthorizedClientService clientService =
new InMemoryReactiveOAuth2AuthorizedClientService(clientRegistrations);
AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager authorizedClientManager =
new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegistrations, clientService);
ServerOAuth2AuthorizedClientExchangeFilterFunction oauth =
new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth.setDefaultClientRegistrationId(REGISTRATION_ID);
webClientBuilder
.defaultHeaders(header -> header.setBearerAuth("TestToken"))
.filter(oauth);
if (log.isDebugEnabled()) {
webClientBuilder
.filter(logRequest())
.filter(logResponse());
}
return webClientBuilder.build();
}
private ExchangeFilterFunction logRequest() {
return (clientRequest, next) -> {
log.info("Request: {} {}", clientRequest.method(), clientRequest.url());
clientRequest.headers()
.forEach((name, values) -> values.forEach(value -> log.info("{}={}", name, value)));
return next.exchange(clientRequest);
};
}
private ExchangeFilterFunction logResponse() {
return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
clientResponse.headers().asHttpHeaders()
.forEach((name, values) -> values.forEach(value -> log.info("{}={}", name, value)));
return Mono.just(clientResponse);
});
}
}
示例模型对象
public class Person extends EntityModel<Person> {
@JsonProperty("person_id")
private String personId;
private String name;
@JsonProperty("external_reference")
private String externalReference;
@JsonProperty("custom_properties")
private List<String> customProperties;
[...]
}
示例 WebClient 用法
return webClient.get()
.uri(baseUrl + URL_PERSONS + "/" + id)
.exchange()
.flatMap(clientResponse -> clientResponse.bodyToMono(Person.class));
来自外部 API 的示例 JSON(_links 部分为我提供了具有上述模型的 NPE,堆栈跟踪如下,或者如果我让 Person 扩展 RepresentationModel 则只是丢失)
{
"_links": {
"self": {
"href": "/api/v1/persons/2f75ab34ea48cab4d4354e4a"
},
"properties": {
"href": "/api/v1/persons/2f75ab34ea48cab4d4354e4a/properties"
},
[...]
},
"person_id": "2f75ab34ea48cab4d4354e4a",
"name": "Jim Doyle",
"external_reference": "1006543",
"custom_properties": null,
[...]
}
带有 EntityModel 的 NPE 的堆栈跟踪
org.springframework.core.codec.DecodingException: JSON decoding error: (was java.lang.NullPointerException); nested exception is com.fasterxml.jackson.databind.JsonMappingException: (was java.lang.NullPointerException) (through reference chain: net.bfgh.api.myapi.model.Person["_links"])
at org.springframework.http.codec.json.AbstractJackson2Decoder.processException(AbstractJackson2Decoder.java:215)
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
|_ checkpoint ⇢ Body from GET http://127.0.0.1:52900 [DefaultClientResponse]
Stack trace:
at org.springframework.http.codec.json.AbstractJackson2Decoder.processException(AbstractJackson2Decoder.java:215)
at org.springframework.http.codec.json.AbstractJackson2Decoder.decode(AbstractJackson2Decoder.java:173)
at org.springframework.http.codec.json.AbstractJackson2Decoder.lambda$decodeToMono$1(AbstractJackson2Decoder.java:159)
at reactor.core.publisher.MonoFlatMap$FlatMapMain.onNext(MonoFlatMap.java:118)
[...]
Caused by: java.lang.NullPointerException
at com.fasterxml.jackson.databind.deser.SettableAnyProperty.deserialize(SettableAnyProperty.java:153)
at com.fasterxml.jackson.databind.deser.SettableAnyProperty.deserializeAndSet(SettableAnyProperty.java:134)
at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.handleUnknownVanilla(BeanDeserializerBase.java:1576)
... 52 more
导致上述错误的测试用例
@Autowired
private WebClient webClient;
@Value(value = "classpath:json-myapi/person.json")
private Resource personJson;
@Before
public void init() throws IOException {
mockWebServer = new MockWebServer();
mockWebServer.start(52900);
mockBaseUrl = "http://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
[...]
}
@Test
public void testSinglePersonJsonToHypermediaModel() throws IOException {
MockResponse mockResponse = new MockResponse()
.addHeader("Content-Type", "application/json") // API's original content type, but also tried setting application/hal+json here
.setBody(new String(personJson.getInputStream().readAllBytes()));
mockWebServer.enqueue(mockResponse);
Person model = webClient.get().uri(mockBaseUrl).exchange()
.flatMap(clientResponse -> clientResponse.bodyToMono(Person.class)).block();
Assertions.assertThat(model).isNotNull();
Assertions.assertThat(model.getName()).isEqualTo("Jim Doyle");
[...]
Assertions.assertThat(model.getLinks().hasSize(7)).isTrue();
[...]
}
解决方案
似乎内容标题 hal+json 是缺失的部分,尽管我很确定我之前尝试过这个。在这两者之间修复之前,可能还有其他问题。至少测试用例现在正在处理这个:
MockResponse mockResponse = new MockResponse()
.addHeader("Content-Type", "application/hal+json") // <-- hal+json!
.setBody(new String(personJson.getInputStream().readAllBytes()));
推荐阅读
- python - DataFrame.loc 不会遍历每一行吗?
- c - 试图编写一个 C 函数以在标准输入中读取结构的元素
- python - Python数据框名称错误:未定义名称
- python - 如果使用python openpyxl特定列中的单元格为空白,如何删除Excel中的行?
- google-apps-script - 根据 if-else 条件编辑单元格的公式
- python - 将二维列表作为值分配给字典
- flutter - 使用 Type Function 并将其传递给 ElevatedButton onPressed、Flutter
- python-3.x - 检查 url 在 Python3 中是否存在
- python-3.x - 有效地查找张量与存储在数据帧列中的所有张量之间的欧几里德/余弦距离
- flutter - Flutter - 两个不同大小的小部件之间的幻灯片转换