10

I have some resource handle methods which contain dozens of @QueryParam parameters with @Default, grouped in roughly themes (pagination/ordering, filtering, authentication). This is really cumbersome and I'd like to simplify this. The good thing is that those parameters are grouped by themes (pagination, ordering, filtering, etc.) so I can reduce my whole set of parameters to 4 methods.

How can I achieve that?

Typically, I want to come from this:

@GET
public Response findAll(
    @QueryParam("sort") @DefaultValue("name") List<String> sort,
    @QueryParam("from") UUID fromId
) {
  // Validate sort
  // Validate fromId
}

To this:

@GET
public Response findAll(@Context Pagination pagination) { // Inject pagination
  // Yeah, small code! Yeah, modularity!
}

// Create the pagination somewhere else.

public Pagination createPagination(@Context UriInfo uriInfo) {
  Optional<UUID> fromId = extractFromId(uriInfo); // retrieve "from" from uriInfo
  List<String> sort = extractSort(uriInfo); // retrieve "sort" from uriInfo
  Pagination pagination = new Pagination();
  pagination.setFromId(fromId);
  pagination.setSort(sort);
  // Validate pagination
  return pagination;
}

Note: as I show in my example, I don't mind writing more code myself, but I just can't bear having too many parameters in my methods and read that wall of @QueryParam + @DefaultValue.

Olivier Grégoire
  • 33,839
  • 23
  • 96
  • 137
  • 2
    If you are using JAX-RS 2.0, see [`@BeanParam`](https://jax-rs-spec.java.net/nonav/2.0-SNAPSHOT/apidocs/javax/ws/rs/BeanParam.html) – Paul Samsotha Nov 03 '15 at 16:30
  • @peeskillet This seems interesting indeed. I don't see it's possible to inject in the constructor though or to use any hook. I'd really love to have an `Optional` member field instead of a nullable `UUID`. – Olivier Grégoire Nov 03 '15 at 16:39
  • 1
    You can inject `@Context UriInfo` into the bean class. Don't annotate the `Optional` field and in the getter, get it through the `UriInfo`. I'm not really sure what you need to inject into the constructor or what hooks you're talking about – Paul Samsotha Nov 03 '15 at 16:41
  • Only other option is to create a `ParamConverter` for `Optional`, which might be something you want to do, if you use `Optional` alot. See [example](http://stackoverflow.com/a/33260715/2587435) – Paul Samsotha Nov 03 '15 at 16:45
  • @peeskillet Thanks a lot! If you want you can write an answer with all that I'll accept it over mine (which I'll delete then). – Olivier Grégoire Nov 03 '15 at 17:10
  • 1
    Although you have found a solution already, I'd advice you to not include authentication parameters like username, password or appkeys as query-parameters for a couple of reasons like caching or copy&pasting issues. They should actually be part of the authentication header of the request. Here a [server-filter](https://jersey.java.net/documentation/latest/filters-and-interceptors.html#d0e9637) may be useful therefore. – Roman Vottner Nov 03 '15 at 17:21
  • @RomanVottner while I totally agree with you in professional cases, this is a small app for me and my friends, not a bank application ;) I'll get there on the long run, it's just overkill now in regards to my needs and to what I want to learn while coding this app. – Olivier Grégoire Nov 03 '15 at 17:26

1 Answers1

13

If you are using JAX-RS 2.0, you can use the @BeanParam, which allows you to inject arbitrary @XxxParam annotated properties and @Context objects into an arbitrary bean class. For example

public class Bean {
    @QueryParam("blah")
    String blah;
}

@GET
public Response get(@BeanParam Bean bean) {}

You can even inject into the constructor, if you want immutibility. For example

public static class Pagination {

    private final List<String> sort;
    private final Optional<String> from;

    public Pagination(@QueryParam("sort") List<String> sort, 
                      @QueryParam("from") Optional<String> from) {
        this.sort = sort;
        this.from = from;
    }

    public List<String> getSort() { return sort; }
    public Optional<String> getFrom() { return from; }
}

If you notice the Optional is being injected. Normally this is not possible, but I created a ParamConverter for it. You can read more about it in this answer. It basically allows us to inject arbitrary objects, create from the String value of the parameter.

@Provider
public static class OptionalParamProvider implements ParamConverterProvider {

    @Override
    public <T> ParamConverter<T> getConverter(Class<T> rawType, 
                                              Type genericType, 
                                              Annotation[] annotations) {
        if (Optional.class != rawType) {
            return null;
        }

        return (ParamConverter<T>)new ParamConverter<Optional>() {

            @Override
            public Optional fromString(String value) {
                return Optional.ofNullable(value);
            }

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

The benefit of the OptionalParamProvider is that it allows you to use Optional anywhere you need to inject a @FormParam, @QueryParam, @PathParm, and all other @XxxParams (except for multitpart).

I don't know what JAX-RS implementation you are using but the above should work on all implementations. Below is a Jersey test case, using Jersey Test Framework. You can run the class like any other JUnit test.

import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.util.List;
import java.util.Optional;
import javax.ws.rs.BeanParam;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ParamConverter;
import javax.ws.rs.ext.ParamConverterProvider;
import javax.ws.rs.ext.Provider;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.test.JerseyTest;
import static org.hamcrest.CoreMatchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import org.junit.Test;

public class BeanParamTest extends JerseyTest {

    @Provider
    public static class OptionalParamProvider implements ParamConverterProvider {

        @Override
        public <T> ParamConverter<T> getConverter(Class<T> rawType, 
                                                  Type genericType, 
                                                  Annotation[] annotations) {
            if (Optional.class != rawType) {
                return null;
            }

            return (ParamConverter<T>)new ParamConverter<Optional>() {

                @Override
                public Optional fromString(String value) {
                    return Optional.ofNullable(value);
                }

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

    public static class Pagination {

        private final List<String> sort;
        private final Optional<String> from;

        public Pagination(@QueryParam("sort") List<String> sort, 
                          @QueryParam("from") Optional<String> from) {
            this.sort = sort;
            this.from = from;
        }

        public List<String> getSort() { return sort; }
        public Optional<String> getFrom() { return from; }
    }

    @Path("bean")
    public static class PaginationResource {

        @GET
        public String get(@BeanParam Pagination pagination) {
            StringBuilder sb = new StringBuilder();
            sb.append(pagination.getSort().toString());
            if (pagination.getFrom().isPresent()) {
                sb.append(pagination.getFrom().get());
            }
            return sb.toString();
        }
    }

    @Override
    public ResourceConfig configure() {
        return new ResourceConfig(PaginationResource.class)
                .register(OptionalParamProvider.class);
    }

    @Test
    public void should_return_all_sort_and_from() {
        Response response = target("bean")
                .queryParam("sort", "foo")
                .queryParam("sort", "bar")
                .queryParam("from", "baz")
                .request().get();
        assertEquals(200, response.getStatus());
        String message = response.readEntity(String.class);
        assertThat(message, containsString("foo"));
        assertThat(message, containsString("bar"));
        assertThat(message, containsString("baz"));
        System.out.println(message);
        response.close();
    }
}

This is the only Maven dependency you need to run the test

<dependency>
    <groupId>org.glassfish.jersey.test-framework.providers</groupId>
    <artifactId>jersey-test-framework-provider-grizzly2</artifactId>
    <version>2.19</version>
    <scope>test</scope>
</dependency>
Community
  • 1
  • 1
Paul Samsotha
  • 205,037
  • 37
  • 486
  • 720
  • Everything is ok, except for the constructor injection: I can't have it working outside of the resource class (for reusability between resources). I'll keep the injector method for now. – Olivier Grégoire Nov 04 '15 at 08:48