Spring WebClient 不读取超媒体链接

问题描述

我正在使用 Spring 的 WebClient 读取带有超媒体链接和 OAuth2 身份验证的外部 API。访问 API 时,JSON 数据会正确转换为模型对象,但如果模型对象扩展 Spring HATEOAS RepresentationModel 或在模型对象扩展 EntityModel 时给出 NullPointerException,则会省略提供的 HAL 链接。我怀疑 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);
    }
}

WebClientConfig

@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,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,[...]
}

带有实体模型的 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()));