Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Multiple @QueryParam keys for a single value in Jersey

Is it possible to allow multiple @QueryParam keys for a single object/variable in Jersey?

enter image description here

Actual:

@POST
public Something getThings(@QueryParam("customer-number") Integer n) {
    ...
}

so, if I add ?customer-number=3 after the URL it works.

Expected:

I want to get the behavior above if I add any of the following values:

  • ?customer-number=3
  • ?customerNumber=3
  • ?customerNo=3

Obs:

  1. The QueryParam annotation looks like:

    ...
    public @interface QueryParam {
       String value();
    }
    

    so, it cannot accept multiple String values (like @Produces).

  2. The approach below allows the user to use multiple keys having the same meaning at the same time (and I want to have an "OR" condition between them):

    @POST
    public Something getThings(@QueryParam("customer-number") Integer n1,
                               @QueryParam("customerNumber") Integer n2,
                               @QueryParam("customerNo") Integer n3) {
        ...
    }
    
  3. Something like this doesn't work:

    @POST
    public Something getThings(@QueryParam("customer-number|customerNumber|customerNo") Integer n) {
        ...
    }
    

How can I do this?

Details:

  • Jersey 2.22.1
  • Java 8
like image 520
ROMANIA_engineer Avatar asked Dec 30 '15 08:12

ROMANIA_engineer


3 Answers

To be honest: this is not how webservices are supposed to be designed. You lay down a strict contract that both client and server follow; you define one parameter and that's it.

But of course it would be a perfect world where you have the freedom to dictate what is going to happen. So if you must allow three parameters in, then you'll have to make that the contract. This is one way following approach #2 which I have to provide without being able to test it for goofs:

public Something getThings(@QueryParam("customer-number") Integer n1,
                           @QueryParam("customerNumber") Integer n2,
                           @QueryParam("customerNo") Integer n3) throws YourFailureException {

  Integer customerNumber = getNonNullValue("Customer number", n1, n2, n3);
  // things with stuff
}

private static Integer getNonNullValue(String label, Integer... params) throws YourFailureException {

  Integer value = null;

  for(Integer choice : params){
    if(choice != null){
      if(value != null){
        // this means there are at least two query parameters passed with a value
        throw new YourFailureException("Ambiguous " + label + " parameters");
      }

      value = choice;
    }
  }

  if(value == null){
    throw new YourFailureException("Missing " + label + " parameter");
  }

  return value;
}

So basically reject any call that does not pass specifically one of the parameters, and let an exception mapper translate the exception you throw into a HTTP response code in the 4xx range of course.

(I made the getNonNullValue() method static is it strikes me as a reusable utility function).

like image 128
Gimby Avatar answered Oct 27 '22 09:10

Gimby


Maybe the simplest and easiest way would be to use a custom @BeanParam:

First define the custom bean merging all the query parameters as:

class MergedIntegerValue {

  private final Integer value;

  public MergedIntegerValue(
      @QueryParam("n1") Integer n1,
      @QueryParam("n2") Integer n2,
      @QueryParam("n3") Integer n3) {
    this.value = n1 != null ? n1 
        : n2 != null ? n2 
        : n3 != null ? n3 
        : null;
    // Throw an exception if value == null ?
  }

  public Integer getValue() {
    return value;
  }
}

and then use it with @BeanParam in your resource method:

public Something getThings(
  @BeanParam MergedIntegerValue n) {

  // Use n.getValue() ...
}

Reference: https://jersey.java.net/documentation/latest/user-guide.html#d0e2403

like image 44
nobeh Avatar answered Oct 27 '22 10:10

nobeh


You can create a custom annotation. I won't go in too much about how to do it, you can see this post, or this post. Basically it relies on a different infrastructure than the usual dependency injection with Jersey. You can see this package from the Jersey project. This is where all the injection providers live that handle the @XxxParam injections. If you examine the source code, you will see the the implementations are fairly the same. The two links I provided above follow the same pattern, as well as the code below.

What I did was created a custom annotation

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface VaryingParam {

    String value();

    @SuppressWarnings("AnnotationAsSuperInterface")
    public static class Factory
            extends AnnotationLiteral<VaryingParam> implements VaryingParam {

        private final String value;

        public static VaryingParam create(final String newValue) {
            return new Factory(newValue);
        }

        public Factory(String newValue) {
            this.value = newValue;
        }

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

It may seem odd that I have a factory to create it, but this was required for the implementation of the below code, where I split the value of the String, and end up creating a new annotation instance for each split value.

Here is the ValueFactoryProvider (which, if you've read either of the above articles, you will see that is required for custom method parameter injection). It a large class, only because I put all the required classes into a single class, following the pattern you see in the Jersey project.

public class VaryingParamValueFactoryProvider extends AbstractValueFactoryProvider {

    @Inject
    public VaryingParamValueFactoryProvider(
            final MultivaluedParameterExtractorProvider mpep,
            final ServiceLocator locator) {
        super(mpep, locator, Parameter.Source.UNKNOWN);
    }

    @Override
    protected Factory<?> createValueFactory(final Parameter parameter) {
        VaryingParam annotation = parameter.getAnnotation(VaryingParam.class);
        if (annotation == null) {
            return null;
        }
        String value = annotation.value();
        if (value == null || value.length() == 0) {
            return null;
        }
        String[] variations = value.split("\\s*\\|\\s*");
        return new VaryingParamFactory(variations, parameter);
    }

    private static Parameter cloneParameter(final Parameter original, final String value) {
        Annotation[] annotations = changeVaryingParam(original.getAnnotations(), value);
        Parameter clone = Parameter.create(
                original.getRawType(),
                original.getRawType(),
                true,
                original.getRawType(),
                original.getRawType(),
                annotations);
        return clone;
    }

    private static Annotation[] changeVaryingParam(final Annotation[] annos, final String value) {
        for (int i = 0; i < annos.length; i++) {
            if (annos[i] instanceof VaryingParam) {
                annos[i] = VaryingParam.Factory.create(value);
                break;
            }
        }
        return annos;
    }

    private class VaryingParamFactory extends AbstractContainerRequestValueFactory<Object> {

        private final String[] variations;
        private final Parameter parameter;
        private final boolean decode;
        private final Class<?> paramType;
        private final boolean isList;
        private final boolean isSet;

        VaryingParamFactory(final String[] variations, final Parameter parameter) {
            this.variations = variations;
            this.parameter = parameter;
            this.decode = !parameter.isEncoded();
            this.paramType = parameter.getRawType();
            this.isList = paramType == List.class;
            this.isSet = paramType == Set.class;
        }

        @Override
        public Object provide() {
            MultivaluedParameterExtractor<?> e = null;
            try {
                Object value = null;
                MultivaluedMap<String, String> params
                        = getContainerRequest().getUriInfo().getQueryParameters(decode);
                for (String variant : variations) {
                    e = get(cloneParameter(parameter, variant));
                    if (e == null) {
                        return null;
                    }
                    if (isList) {
                        List list = (List<?>) e.extract(params);
                        if (value == null) {
                            value = new ArrayList();
                        }
                        ((List<?>) value).addAll(list);
                    } else if (isSet) {
                        Set set = (Set<?>) e.extract(params);
                        if (value == null) {
                            value = new HashSet();
                        }
                        ((Set<?>) value).addAll(set);
                    } else {
                        value = e.extract(params);
                        if (value != null) {
                            return value;
                        }         
                    }
                }
                return value;
            } catch (ExtractorException ex) {
                if (e == null) {
                    throw new ParamException.QueryParamException(ex.getCause(),
                            parameter.getSourceName(), parameter.getDefaultValue());
                } else {
                    throw new ParamException.QueryParamException(ex.getCause(),
                            e.getName(), e.getDefaultValueString());
                }
            }
        }
    }

    private static class Resolver extends ParamInjectionResolver<VaryingParam> {

        public Resolver() {
            super(VaryingParamValueFactoryProvider.class);
        }
    }

    public static class Binder extends AbstractBinder {

        @Override
        protected void configure() {
            bind(VaryingParamValueFactoryProvider.class)
                    .to(ValueFactoryProvider.class)
                    .in(Singleton.class);
            bind(VaryingParamValueFactoryProvider.Resolver.class)
                    .to(new TypeLiteral<InjectionResolver<VaryingParam>>() {
                    })
                    .in(Singleton.class);
        }
    }
}

You will need to register this class' Binder (bottom of class) with Jersey to use it.

What differentiates this class from Jersey QueryParamValueFactoryProvider is that instead of just processing a single String value of the annotation, it splits the value, and tries to extract the values from the query param map. The first value found will be returned. If the parameter is a List or Set, it just continues to keep looking up all the options, and adding them to the list.

For the most part this keeps all the functionality you would expect from an @XxxParam annotation. The only thing that was difficult to implement (so I left out supporting this use case), is multiple parameters, e.g.

@GET
@Path("multiple")
public String getMultipleVariants(@VaryingParam("param-1|param-2|param-3") String value1,
                                  @VaryingParam("param-1|param-2|param-3") String value2) {
    return value1 + ":" + value2;
}

I actually don't think it should be that hard to implement, if you really need it, it's just a matter of creating a new MultivaluedMap, removing a value if it is found. This would be implemented in the provide() method of the VaryingParamFactory above. If you need this use case, you could just use a List or Set instead.

See this GitHub Gist (it's rather long) for a complete test case, using Jersey Test Framework. You can see all the use cases I tested in the QueryTestResource, and where I register the Binder with the ResourceConfig in the test configure() method.

like image 28
Paul Samsotha Avatar answered Oct 27 '22 11:10

Paul Samsotha