7

I've made a ParamConverter which provides an Instant (Date) when given a string formatted as either Instant's native ISO-8601, or as an integer number of milliseconds since the epoch. This is working fine, but I also need to be able to support other date formats (the customers are fussy).

To avoid the classic dd/mm/yyyy vs mm/dd/yyyy ambiguity, I'd like to have the customer specify their preferred format as part of the request*. e.g:

GET http://api.example.com/filter?since=01/02/2000&dateformat=dd/mm/yyyy

passed to a method which looks like:

@GET
String getFilteredList( final @QueryParam( "since" ) Instant since ) {
    ...
}

(time & timezone parts omitted for clarity)

So I'd like my ParamConverter<Instant> to be able to read the dateformat parameter.

I've been able to use a combination of a filter which sets a ContainerRequestContext property and an AbstractValueFactoryProvider to do something similar, but that needs the parameter to have a custom annotation applied and doesn't let it work with QueryParam/FormParam/etc., making it far less useful.

Is there any way to get other parameters, or the request object itself, from inside a ParamConverter?

[*] In the real world this would be from a selection of pre-approved formats, but for now just assume they're providing the input to a DateTimeFormatter


For clarity, here's the code I have:

public class InstantParameterProvider implements ParamConverterProvider {
    private static final ParamConverter<Instant> INSTANT_CONVERTER =
            new ParamConverter<Instant>( ) {
                @Override public final T fromString( final String value ) {
                    // This is where I would like to get the other parameter's value
                    // Is it possible?
                }

                @Override public final String toString( final T value ) {
                    return value.toString( );
                }
            };

    @SuppressWarnings( "unchecked" )
    @Override public <T> ParamConverter<T> getConverter(
            final Class<T> rawType,
            final Type genericType,
            final Annotation[] annotations
    ) {
        if( rawType == Instant.class ) {
            return (ParamConverter<T>) INSTANT_CONVERTER;
        }
        return null;
    }
}
Paul Samsotha
  • 205,037
  • 37
  • 486
  • 720
Dave
  • 44,275
  • 12
  • 65
  • 105
  • No, it's not possible. There is not enough information in either the `ParamConverter` nor the `ParamConverterProvider` to give you access to other params. You would have to do this logic from within your method (take the two arguments, pass them to some other method to get an `Instant` – John Ament Dec 21 '14 at 14:34
  • I'm not tied to using a ParamConverter specifically; as I noted, I tried an AbstractValueFactoryProvider but hit a different issue (that I can't link it to a QueryParam/etc.). Is there really no way at all to do this without changing the method signature? – Dave Dec 21 '14 at 15:38
  • 1
    The only way I can think of is to use a `ContainerRequestFilter` to get the params, and then set a property on the `ContainerRequestContext` with the resulting `Instant` object. You should be able to inject the context per https://java.net/jira/browse/JERSEY-2355 but I'm not 100% sure, give it a shot. – John Ament Dec 21 '14 at 18:44

1 Answers1

7

As mentioned here, the key to this is injecting some context object with javax.inject.Provider, which allows us to retrieve the object lazily. Since the ParamConverterProvider is a component managed by Jersey, we should be able to inject other components.

The problem is that the component we need is going to be in a request scope. To get around that, we inject javax.inject.Provider<UriInfo> into the provider. When we actually call get() in the Provider to get the actual instance of UriInfo, it will be be in a request. The same goes for any other component that requires a request scope.

For example

public class InstantParamProvider implements ParamConverterProvider {
    
    @Inject
    private javax.inject.Provider<UriInfo> uriInfoProvider;

    @Override
    public <T> ParamConverter<T> getConverter(Class<T> rawType, 
                                              Type genericType, 
                                              Annotation[] annotations) {

        if (rawType != Instant.class) return null; 

        return new ParamConverter<T>() {
            @Override
            public T fromString(String value) {
                UriInfo uriInfo = uriInfoProvider.get();
                String format = uriInfo.getQueryParameters().getFirst("date-format");
                
                if (format == null) {
                     throw new WebApplicationException(Response.status(400)
                             .entity("data-format query parameter required").build());
                } else {
                    try {
                        // parse and return here
                    } catch (Exception ex) {
                        throw new WebApplicationException(
                            Response.status(400).entity("Bad format " + format).build());
                    }
                }
            }

            @Override
            public String toString(T value) {
                return value.toString();
            }  
        };
    }  
}

UPDATE

Here is a complete example using Jersey Test Framework

import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.logging.Logger;

import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import javax.ws.rs.ext.ParamConverter;
import javax.ws.rs.ext.ParamConverterProvider;

import org.glassfish.jersey.filter.LoggingFilter;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.test.JerseyTest;

import org.junit.Test;
import static org.junit.Assert.*;
import static org.junit.matchers.JUnitMatchers.*;

public class LocalDateTest extends JerseyTest {

    public static class LocalDateParamProvider implements ParamConverterProvider {

        @Inject
        private javax.inject.Provider<UriInfo> uriInfoProvider;

        @Override
        public <T> ParamConverter<T> getConverter(Class<T> rawType,
                Type genericType,
                Annotation[] annotations) {

            if (rawType != LocalDate.class) {
                return null;
            }
            return new ParamConverter<T>() {
                @Override
                public T fromString(String value) {
                    UriInfo uriInfo = uriInfoProvider.get();
                    String format = uriInfo.getQueryParameters().getFirst("date-format");

                    if (format == null) {
                        throw new WebApplicationException(Response.status(400)
                                .entity("date-format query parameter required").build());
                    } else {
                        try {
                            return (T) LocalDate.parse(value, DateTimeFormatter.ofPattern(format));
                            // parse and return here
                        } catch (Exception ex) {
                            throw new WebApplicationException(
                                    Response.status(400).entity("Bad format " + format).build());
                        }
                    }
                }

                @Override
                public String toString(T value) {
                    return value.toString();
                }
            };
        }
    }

    @Path("localdate")
    public static class LocalDateResource {

        @GET
        public String get(@QueryParam("since") LocalDate since) {
            return since.format(DateTimeFormatter.ofPattern("MM/dd/yyyy"));
        }
    }

    @Override
    public ResourceConfig configure() {
        return new ResourceConfig(LocalDateResource.class)
                .register(LocalDateParamProvider.class)
                .register(new LoggingFilter(Logger.getAnonymousLogger(), true));
    }

    @Test
    public void should_return_bad_request_with_bad_format() {
        Response response = target("localdate")
                .queryParam("since", "09/20/2015")
                .queryParam("date-format", "yyyy/MM/dd")
                .request().get();
        assertEquals(400, response.getStatus());
        assertThat(response.readEntity(String.class), containsString("format yyyy/MM/dd"));
        response.close();
    }

    @Test
    public void should_return_bad_request_with_no_date_format() {
        Response response = target("localdate")
                .queryParam("since", "09/20/2015")
                .request().get();
        assertEquals(400, response.getStatus());
        assertThat(response.readEntity(String.class), containsString("query parameter required"));
        response.close();
    }

    @Test
    public void should_succeed_with_correct_format() {
        Response response = target("localdate")
                .queryParam("since", "09/20/2015")
                .queryParam("date-format", "MM/dd/yyyy")
                .request().get();
        assertEquals(200, response.getStatus());
        assertThat(response.readEntity(String.class), containsString("09/20/2015"));
        response.close();
    }
}

Here's the test dependency

<dependency>
    <groupId>org.glassfish.jersey.test-framework.providers</groupId>
    <artifactId>jersey-test-framework-provider-grizzly2</artifactId>
    <version>${jersey2.version}</version>
    <scope>test</scope>
</dependency>
Community
  • 1
  • 1
Paul Samsotha
  • 205,037
  • 37
  • 486
  • 720
  • It looks like exactly what I want, but unfortunately I keep getting `Could not process parameter type class java.time.Instant` followed by `java.lang.IllegalStateException: Not inside a request scope.`. I'll see if I can figure out what's going on with that - hopefully this is the answer! – Dave Sep 20 '15 at 15:27
  • I actually tested this, and i works fine. You need to be careful where you are calling `get()` to get the `UriInfo`. It _must_ be called inside the `fromString` – Paul Samsotha Sep 20 '15 at 18:03
  • What happens is that during application startup, Jersey validates all the resource methods to make sure they are correct. In order to do that, it needs to check if there is a converter for the `Instant` type, so it actually calls the converter provider to see if it return a `ParamConverter`. It doesn't try to call the converter method, but just checks it there _is_ one returned. So if you try to call `get()` inside the `getConvereter()` method, it will be called during model validation on application load, which is not inside a request scope. – Paul Samsotha Sep 20 '15 at 18:12
  • I made that mistake at first, then realised the error and changed it. Maybe it didn't re-deploy properly. There are also a couple of other differences (e.g. I had the provider marked as `@Singleton`), so I'm just working through the various differences to see what's breaking this for me. – Dave Sep 20 '15 at 18:53
  • Well, adding a dumb try-catch to ignore the exception makes it work. I'm not sure why it's trying to call it at startup (the exception is definitely thrown from within the fromString method), but I'll continue to investigate that. The more important thing is: it works :) – Dave Sep 20 '15 at 19:09
  • The thing you need to be careful about is that any exception that occurs in the `ParamConverter`, that is not handled by throwing a `WebApplicationException`, will lead to a 404 with no error message to help in debugging. That's why you see me throwing it above. – Paul Samsotha Sep 20 '15 at 19:18
  • I tracked it down: it was an `@DefaultValue`, which I guess it must pre-parse (or maybe I changed a setting somewhere to make it do that). Fortunately the default value is only there for testing - there aren't any real default instants. – Dave Sep 20 '15 at 19:31