首页 > 解决方案 > 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();
        [...]
    }

标签: javaspringwebclientspring-webfluxspring-hateoas

解决方案


似乎内容标题 hal+json 是缺失的部分,尽管我很确定我之前尝试过这个。在这两者之间修复之前,可能还有其他问题。至少测试用例现在正在处理这个:

MockResponse mockResponse = new MockResponse()
                .addHeader("Content-Type", "application/hal+json") //      <-- hal+json! 
                .setBody(new String(personJson.getInputStream().readAllBytes()));

推荐阅读