Search code examples
javaspringwebclientspring-webfluxspring-hateoas

Spring WebClient does not read hypermedia links


I am reading from an external API with hypermedia links and OAuth2 authentication using Spring's WebClient. When accessing the API the JSON data is correctly converted to model objects but the supplied HAL links are either omitted if the model object extends Spring HATEOAS RepresentationModel or give a NullPointerException when the model object extends EntityModel. I suspect a problem with the hypermediaWebClientCustomizer but was not able to solve it as of now.

I tried reading the JSON with a Traverson client in a testcase. That was basically working, if i replaced relative URIs with absolute URIs and the application/json header with a application/hal+json header. I would go on with Traverson but besides these two problems Traverson requires a RestTemplate (OAuth2RestTemplate in this case), which is no longer available in our Spring version.

Any ideas if there is a problem with the configuration or what else could go wrong?

This is my configuration:

dependencies (in part)

    <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>

Application config

@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);
        });
    }
}

example model 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;
    
    [...]

}

example WebClient usage

        return webClient.get()
                .uri(baseUrl + URL_PERSONS + "/" + id)
                .exchange()
                .flatMap(clientResponse -> clientResponse.bodyToMono(Person.class));

example JSON from external API (the _links part gives me a NPE with the above model, stacktrace follows below, or is just missing if i let Person extend 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,
  [...]
}

stacktrace for NPE with EntityModel

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    

testcase causing the above error

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


Solution

  • It seems the content-header hal+json was the missing piece, although i'm quite sure i tried this before. Probably something else was wrong before that has been fixed in between. At least the test case is now working with this:

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