44

Is there any reason not to map Controllers as interfaces?

In all the examples and questions I see surrounding controllers, all are concrete classes. Is there a reason for this? I would like to separate the request mappings from the implementation. I hit a wall though when I tried to get a @PathVariable as a parameter in my concrete class.

My Controller interface looks like this:

@Controller
@RequestMapping("/services/goal/")
public interface GoalService {

    @RequestMapping("options/")
    @ResponseBody
    Map<String, Long> getGoals();

    @RequestMapping(value = "{id}/", method = RequestMethod.DELETE)
    @ResponseBody
    void removeGoal(@PathVariable String id);

}

And the implementing class:

@Component
public class GoalServiceImpl implements GoalService {

    /* init code */

    public Map<String, Long> getGoals() {
        /* method code */
        return map;
    }

    public void removeGoal(String id) {
        Goal goal = goalDao.findByPrimaryKey(Long.parseLong(id));
        goalDao.remove(goal);
    }

}

The getGoals() method works great; the removeGoal(String id) throws an exception

ExceptionHandlerExceptionResolver - Resolving exception from handler [public void
    todo.webapp.controllers.services.GoalServiceImpl.removeGoal(java.lang.String)]: 
    org.springframework.web.bind.MissingServletRequestParameterException: Required 
    String parameter 'id' is not present

If I add the @PathVariable annotation to the concrete class everything works as expected, but why should i have to re-declare this in the concrete class? Shouldn't it be handled by whatever has the @Controller annotation?

AlikElzin-kilaka
  • 34,335
  • 35
  • 194
  • 277
willscripted
  • 1,438
  • 1
  • 15
  • 25
  • 2
    Looks like I just didn't understand annotation inheritance, i'll post my explanation after I wait for my 8 hour timeout to expire – willscripted Nov 04 '11 at 01:37

5 Answers5

29

Apparently, when a request pattern is mapped to a method via the @RequestMapping annotation, it is mapped to to the concrete method implementation. So a request that matches the declaration will invoke GoalServiceImpl.removeGoal() directly rather than the method that originally declared the @RequestMapping ie GoalService.removeGoal().

Since an annotation on an interface, interface method, or interface method parameter does not carry over to the implementation there is no way for Spring MVC to recognize this as a @PathVariable unless the implementing class declares it explicitly. Without it, any AOP advice that targets @PathVariable parameters will not be executed.

willscripted
  • 1,438
  • 1
  • 15
  • 25
17

The feature of defining all bindings on interface actually got implement recently in Spring 5.1.5.

Please see this issue: https://github.com/spring-projects/spring-framework/issues/15682 - it was a struggle :)

Now you can actually do:

@RequestMapping("/random")
public interface RandomDataController {

    @RequestMapping(value = "/{type}", method = RequestMethod.GET)
    @ResponseBody
    RandomData getRandomData(
            @PathVariable(value = "type") RandomDataType type, @RequestParam(value = "size", required = false, defaultValue = "10") int size);
}
@Controller
public class RandomDataImpl implements RandomDataController {

    @Autowired
    private RandomGenerator randomGenerator;

    @Override
    public RandomData getPathParamRandomData(RandomDataType type, int size) {
        return randomGenerator.generateRandomData(type, size);
    }
}

You can even use this library: https://github.com/ggeorgovassilis/spring-rest-invoker

To get a client-proxy based on that interface, similarly to how RestEasys client framework works in the JAX-RS land.

Adam from WALCZAK.IT
  • 1,339
  • 12
  • 11
10

It works in newer version of Spring.

import org.springframework.web.bind.annotation.RequestMapping;
public interface TestApi {
    @RequestMapping("/test")
    public String test();
}

Implement the interface in the Controller

@RestController
@Slf4j
public class TestApiController implements TestApi {

    @Override
    public String test() {
        log.info("In Test");
        return "Value";
    }

}

It can be used as: Rest client

Sabuj Das
  • 287
  • 3
  • 4
  • 2
    Have you tested this with annotated parameters? – willscripted Oct 11 '16 at 15:58
  • @Sabuh Das what version are you using ? – kozla13 Dec 02 '16 at 11:08
  • 3
    spring 4.3.* works. But there is no params in this example. spring-mvc handler adapters are not aware of any annotated params in interface method. And there is some workaround: Interface method with spring-mvc annotations can have default implementation. This implementation delegates call to another _abstract_ method, which is implemented by class with `@RestController` annotation. In this scenario spring binds http request handler to interface method with default implementation. [See simple example](https://gist.github.com/milovtim/d90e4aed64860658479423235e4fac97#file-democontroller-java) – Timur Milovanov Jan 09 '17 at 13:11
  • 1
    @TimurMilovanov The simple example you provide seems to contain an error. The controller override the default interface method `sayHello` instead of `sayHelloImpl`. I don't think this code even compiles. But I tried what you mean and the workaround works fine ;) – Gerard Bosch Oct 21 '18 at 21:28
  • @GerardBosch Yep, thank you! Gist fixed -- class overrides abstract method. – Timur Milovanov Oct 23 '18 at 11:01
  • @TimurMilovanov your comment is a very complete answer. You could publish it as an answer instead of a comment as it is very valuable and provides a working workaround example for the case of Spring previous to 4.3 ;) – Gerard Bosch Oct 23 '18 at 20:30
  • full support for annotations on interfaces got added recently - please see my answer below and upvote – Adam from WALCZAK.IT Mar 13 '19 at 19:09
1

Recently I had the same problem. Following has worked for me:

public class GoalServiceImpl implements GoalService {
    ...
    public void removeGoal(@PathVariableString id) {
    }
}
Dharman
  • 30,962
  • 25
  • 85
  • 135
0

i resolved this problem.

ON CLIENT SIDE:

I'm using this library https://github.com/ggeorgovassilis/spring-rest-invoker/. This library generate a proxy from interface to invoke spring rest service.

I extended this library:

I created an annotations and a factory client class:

Identify a Spring Rest Service

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SpringRestService {
    String baseUri();
}

This class generates a client rest from interfaces

public class RestFactory implements BeanFactoryPostProcessor,EmbeddedValueResolverAware  {

    StringValueResolver resolver;

    @Override
    public void setEmbeddedValueResolver(StringValueResolver resolver) {
        this.resolver = resolver;
    }
    private String basePackage = "com";

    public void setBasePackage(String basePackage) {
        this.basePackage = basePackage;
    }


    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        createBeanProxy(beanFactory,SpringRestService.class);
        createBeanProxy(beanFactory,JaxrsRestService.class);
    }

    private void createBeanProxy(ConfigurableListableBeanFactory beanFactory,Class<? extends Annotation> annotation) {
        List<Class<Object>> classes;
        try {
            classes = AnnotationUtils.findAnnotatedClasses(basePackage, annotation);
        } catch (Exception e) {
            throw new BeanInstantiationException(annotation, e.getMessage(), e);
        }
        BeanDefinitionRegistry registry = (BeanDefinitionRegistry) beanFactory;
        for (Class<Object> classType : classes) {
            Annotation typeService = classType.getAnnotation(annotation);   
            GenericBeanDefinition beanDef = new GenericBeanDefinition();
            beanDef.setBeanClass(getQueryServiceFactory(classType, typeService));
            ConstructorArgumentValues cav = new ConstructorArgumentValues();
            cav.addIndexedArgumentValue(0, classType);
            cav.addIndexedArgumentValue(1, baseUri(classType,typeService));
            beanDef.setConstructorArgumentValues(cav);
            registry.registerBeanDefinition(classType.getName() + "Proxy", beanDef);
        }
    }

    private String baseUri(Class<Object> c,Annotation typeService){
        String baseUri = null;
        if(typeService instanceof SpringRestService){
            baseUri = ((SpringRestService)typeService).baseUri();  
        }else if(typeService instanceof JaxrsRestService){
            baseUri = ((JaxrsRestService)typeService).baseUri();
        }
        if(baseUri!=null && !baseUri.isEmpty()){
            return baseUri = resolver.resolveStringValue(baseUri);
        }else{
            throw new IllegalStateException("Impossibile individuare una baseUri per l'interface :"+c);
        }
    }

    private static Class<? extends FactoryBean<?>> getQueryServiceFactory(Class<Object> c,Annotation typeService){
        if(typeService instanceof SpringRestService){
            return it.eng.rete2i.springjsonmapper.spring.SpringRestInvokerProxyFactoryBean.class;  
        }else if(typeService instanceof JaxrsRestService){
            return it.eng.rete2i.springjsonmapper.jaxrs.JaxRsInvokerProxyFactoryBean.class;
        }
        throw new IllegalStateException("Impossibile individuare una classe per l'interface :"+c);
    }
}

I configure my factory:

<bean class="it.eng.rete2i.springjsonmapper.factory.RestFactory">
    <property name="basePackage" value="it.giancarlo.rest.services" />
</bean>

ON REST SERVICE SIGNATURE

this is an example interface:

package it.giancarlo.rest.services.spring;

import ...

@SpringRestService(baseUri="${bookservice.url}")
public interface BookService{

    @Override
    @RequestMapping("/volumes")
    QueryResult findBooksByTitle(@RequestParam("q") String q);

    @Override
    @RequestMapping("/volumes/{id}")
    Item findBookById(@PathVariable("id") String id);

}

ON REST SERVICE IMPLEMENTATION

Service implementation

@RestController
@RequestMapping("bookService")
public class BookServiceImpl implements BookService {
    @Override
    public QueryResult findBooksByTitle(String q) {
        // TODO Auto-generated method stub
        return null;
    }
    @Override
    public Item findBookById(String id) {
        // TODO Auto-generated method stub
        return null;
    }
}

To resolve annotation on parameters I create a custom RequestMappingHandlerMapping that looks all interfaces annotated with @SpringRestService

public class RestServiceRequestMappingHandlerMapping extends RequestMappingHandlerMapping{


    public HandlerMethod testCreateHandlerMethod(Object handler, Method method){
        return createHandlerMethod(handler, method);
    }

    @Override
    protected HandlerMethod createHandlerMethod(Object handler, Method method) {
        HandlerMethod handlerMethod;
        if (handler instanceof String) {
            String beanName = (String) handler;
            handlerMethod = new RestServiceHandlerMethod(beanName,getApplicationContext().getAutowireCapableBeanFactory(), method);
        }
        else {
            handlerMethod = new RestServiceHandlerMethod(handler, method);
        }
        return handlerMethod;
    }


    public static class RestServiceHandlerMethod extends HandlerMethod{

        private Method interfaceMethod;


        public RestServiceHandlerMethod(Object bean, Method method) {
            super(bean,method);
            changeType();
        }

        public RestServiceHandlerMethod(Object bean, String methodName, Class<?>... parameterTypes) throws NoSuchMethodException {
            super(bean,methodName,parameterTypes);
            changeType();
        }

        public RestServiceHandlerMethod(String beanName, BeanFactory beanFactory, Method method) {
            super(beanName,beanFactory,method);
            changeType();
        }


        private void changeType(){
            for(Class<?> clazz : getMethod().getDeclaringClass().getInterfaces()){
                if(clazz.isAnnotationPresent(SpringRestService.class)){
                    try{
                        interfaceMethod = clazz.getMethod(getMethod().getName(), getMethod().getParameterTypes());
                        break;      
                    }catch(NoSuchMethodException e){

                    }
                }
            }
            MethodParameter[] params = super.getMethodParameters();
            for(int i=0;i<params.length;i++){
                params[i] = new RestServiceMethodParameter(params[i]);
            }
        }




        private class RestServiceMethodParameter extends MethodParameter{

            private volatile Annotation[] parameterAnnotations;

            public RestServiceMethodParameter(MethodParameter methodParameter){
                super(methodParameter);
            }


            @Override
            public Annotation[] getParameterAnnotations() {
                if (this.parameterAnnotations == null){
                        if(RestServiceHandlerMethod.this.interfaceMethod!=null) {
                            Annotation[][] annotationArray = RestServiceHandlerMethod.this.interfaceMethod.getParameterAnnotations();
                            if (this.getParameterIndex() >= 0 && this.getParameterIndex() < annotationArray.length) {
                                this.parameterAnnotations = annotationArray[this.getParameterIndex()];
                            }
                            else {
                                this.parameterAnnotations = new Annotation[0];
                            }
                        }else{
                            this.parameterAnnotations = super.getParameterAnnotations();
                        }
                }
                return this.parameterAnnotations;
            }

        }

    }

}

I created a configuration class

@Configuration
public class WebConfig extends WebMvcConfigurationSupport{

    @Bean
    public RequestMappingHandlerMapping requestMappingHandlerMapping() {
        RestServiceRequestMappingHandlerMapping handlerMapping = new RestServiceRequestMappingHandlerMapping();
        handlerMapping.setOrder(0);
        handlerMapping.setInterceptors(getInterceptors());
        handlerMapping.setContentNegotiationManager(mvcContentNegotiationManager());

        PathMatchConfigurer configurer = getPathMatchConfigurer();
        if (configurer.isUseSuffixPatternMatch() != null) {
            handlerMapping.setUseSuffixPatternMatch(configurer.isUseSuffixPatternMatch());
        }
        if (configurer.isUseRegisteredSuffixPatternMatch() != null) {
            handlerMapping.setUseRegisteredSuffixPatternMatch(configurer.isUseRegisteredSuffixPatternMatch());
        }
        if (configurer.isUseTrailingSlashMatch() != null) {
            handlerMapping.setUseTrailingSlashMatch(configurer.isUseTrailingSlashMatch());
        }
        if (configurer.getPathMatcher() != null) {
            handlerMapping.setPathMatcher(configurer.getPathMatcher());
        }
        if (configurer.getUrlPathHelper() != null) {
            handlerMapping.setUrlPathHelper(configurer.getUrlPathHelper());
        }
        return handlerMapping;
    }
}

and I configurated it

<bean class="....WebConfig" />