Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HttpServiceProxyFactory should omit optional @RequestParam if converted from null to empty string #33794

Closed
ftreede opened this issue Oct 25, 2024 · 4 comments
Assignees
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) type: enhancement A general enhancement
Milestone

Comments

@ftreede
Copy link

ftreede commented Oct 25, 2024

Spring version: 6.1.14

When using HttpServiceProxyFactory, we can define request parameters as optional, so that a value of null leads to the parameter being omitted completely:

@GetExchange("/test")
void testString(@RequestParam(value = "param", required = false) String param);

Here, calling testString(null) will result in a request with url /test. The query parameter is omitted completely.

However, If the value is not a string and run through conversion, this does not work.
In this example:

@GetExchange("/test")
void testDate(@RequestParam(value = "param", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime param);

Calling testDate(null) makes a request with /test?param=, setting the query parameter to an empty string.

This behaviour is inconsistent and counterintuitive. Also, a server expecting a date value in the parameter is likely to not accept an empty string.

You can work around this by customizing the conversion service or the argument resolvers in the HttpServiceProxyFactory, but there really should be a consistent solution in spring itself.

A full test case for reference:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpMethod;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;

import java.time.OffsetDateTime;

import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;

class HttpInterfaceConvertedNullTest {

    private MockRestServiceServer mockServer;
    private HttpInterface httpInterface;

    @BeforeEach
    void setUp() {
        // setup client and mock server
        RestClient.Builder rcb = RestClient.builder();
        mockServer = MockRestServiceServer.bindTo(rcb).build();
        rcb.baseUrl("http://example.com");

        // make http interface
        httpInterface = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(rcb.build()))
                .build()
                .createClient(HttpInterface.class);
    }

    @Test
    void testDateNull() {
        // setup expectations - the requestTo expectation also requires there to be no query parameters
        mockServer.expect(requestTo("http://example.com/test"))
                .andExpect(method(HttpMethod.GET))
                .andRespond(withSuccess());

        // make call
        httpInterface.testDate(null);

        // verify
        mockServer.verify();
    }

    @Test
    void testStringNull() {
        // setup expectations - the requestTo expectation also requires there to be no query parameters
        mockServer.expect(requestTo("http://example.com/test"))
                .andExpect(method(HttpMethod.GET))
                .andRespond(withSuccess());

        // make call
        httpInterface.testString(null);

        // verify
        mockServer.verify();
    }


    interface HttpInterface {
        @GetExchange("/test")
        void testDate(@RequestParam(value = "param", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime param);

        @GetExchange("/test")
        void testString(@RequestParam(value = "param", required = false) String param);
    }

}
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Oct 25, 2024
@jhoeller jhoeller added the in: web Issues in web modules (web, webmvc, webflux, websocket) label Oct 25, 2024
@br1ghtt
Copy link

br1ghtt commented Oct 30, 2024

From what I see, the AbstractNamedValueArgumentResolver.addSingleValue uses a different converter based on the parameter type (String.class -> GenericConversionService.NoOpConverter, OffsetDateTime.class -> FormattingConversionService.PrinterConverter). Their convert method behave slightly different, when it comes to null values. NoOpsConverter returns null in case of a null source object, and on the other hand PrinterConverter returns empty string. AbstractNamedValueArgumentResolver.addSingleValue does not add the parameter in case of a not required null value. In case of empty string it adds a addRequestValue. So we could change printer converter or change addSingleValue to also not add on empty string. Does someone understand the impact when doing this change?

@ftreede
Copy link
Author

ftreede commented Oct 31, 2024

I can definetely think of usecases where I'd want to explicitly pass an empty string as parameter.

A better approach would be to just not call the conversion service for null values. It is already getting skipped if the value is a string.

@br1ghtt
Copy link

br1ghtt commented Oct 31, 2024

I fully agree with you about passing empty strings as query parameter values.

With the following test I was able to reproduce the case when passing null to a not required converted request param in NamedValueArgumentResolverTests

@GetExchange
void executeDateNotRequired(@Nullable @TestValue(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate value);

@Test
void dateTestNotRequiredNull() {
	this.service.executeDateNotRequired(null);
	assertTestValue("value");
}

And then skip conversion when value null in AbstractNamedValueArgumentResolver#addSingleValue

if (value != null && this.conversionService != null && !(value instanceof String)) {
	//
}

if (value == null) {
    Assert.isTrue(!required, () -> "Missing " + valueLabel + " value '" + name + "'");
    return;
}

If you agree with this I can submit a PR.

@rstoyanchev
Copy link
Contributor

rstoyanchev commented Nov 1, 2024

I don't think we need to prevent null values from conversion, but we can check after conversion if the value is empty and was null before. If it is not required, reset it back to null.

We have similar post conversion checks on the server side, but in the opposite direction where request values are converted to objects.

@rstoyanchev rstoyanchev self-assigned this Nov 1, 2024
@rstoyanchev rstoyanchev added type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Nov 1, 2024
@rstoyanchev rstoyanchev added this to the 6.2.0 milestone Nov 1, 2024
@rstoyanchev rstoyanchev changed the title HttpServiceProxyFactory: Unable to omit optional RequestParam if converted HttpServiceProxyFactory should omit optional @RequestParam if converted from null to empty string Nov 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) type: enhancement A general enhancement
Projects
None yet
Development

No branches or pull requests

5 participants