Spring WebClient 不读取超媒体链接

Spring WebClient does not read hypermedia links

我正在使用 Spring 的 WebClient 从外部 API 读取超媒体链接和 OAuth2 身份验证。访问 API 时,JSON 数据正确转换为模型 objects,但如果模型 object 扩展 Spring HATEOAS RepresentationModel,则提供的 HAL 链接将被省略或者在模型 object 扩展 EntityModel 时给出 NullPointerException。我怀疑 hypermediaWebClientCustomizer 有问题,但目前无法解决。

我尝试在测试用例中使用 Traverson 客户端阅读 JSON。如果我将相对 URI 替换为绝对 URI 并将 application/json header 替换为 application/hal+json header,那基本上是可行的。我会继续使用 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, values) -> values.forEach(value -> log.info("{}={}", name, value)));
            return Mono.just(clientResponse);
        });
    }
}

示例模型 object

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(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();
        [...]
    }

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

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

我遇到了同样的问题,无法在客户端访问 links。在我的设置中,服务器和客户端似乎来自不同的堆栈世代 (WebFlux-Client / WebMVC-Server),其中 link 部分的 属性 名称在默认情况下略有不同( “_links”与“links”).

幸运的是我能够更改 server-side 所以这段代码 (Kotlin) 让我 WebMVC-Server 和 WebClient 一起工作。

服务器:

@Configuration
@EnableHypermediaSupport(type = [HAL], stacks = [WEBFLUX])
class HalConfiguration

这会在 属性 名称“links”下生成一个 link 部分,WebClient 默认理解该部分。