Search code examples
javarestspring-bootjerseyjersey-2.0

Listing all deployed rest endpoints (spring-boot, jersey)


Is it possible to list all my configured rest-endpoints with spring boot? The actuator lists all existing paths on startup, I want something similar for my custom services, so I can check on startup if all paths are configured correctly and use this info for client calls.

How do I do this? I use @Path/@GET annotations on my service beans and register them via ResourceConfig#registerClasses.

Is there a way to query the Config for all Paths?

Update: I register the REST Controllers via

@Bean
public ResourceConfig resourceConfig() {
   return new ResourceConfig() {
    {  
      register(MyRestController.class);
    }
   };
}

Update2: I want to have something like

GET /rest/mycontroller/info
POST /res/mycontroller/update
...

Motivation: when the spring-boot app started, I want to print out all registered controllers and their paths, so I can stop guessing which endpoints to use.


Solution

  • Probably the best way to do this, is to use an ApplicationEventListener. From there you can listen for the "application finished initializing" event, and get the ResourceModel from the ApplicationEvent. The ResourceModel will have all the initialized Resources. Then you can traverse the Resource as others have mentioned. Below is an implementation. Some of the implementation has been taken from the DropwizardResourceConfig implementation.

    import com.fasterxml.classmate.ResolvedType;
    import com.fasterxml.classmate.TypeResolver;
    import java.util.Comparator;
    import java.util.HashSet;
    import java.util.Set;
    import java.util.TreeSet;
    import org.glassfish.jersey.server.model.Resource;
    import org.glassfish.jersey.server.model.ResourceMethod;
    import org.glassfish.jersey.server.model.ResourceModel;
    import org.glassfish.jersey.server.monitoring.ApplicationEvent;
    import org.glassfish.jersey.server.monitoring.ApplicationEventListener;
    import org.glassfish.jersey.server.monitoring.RequestEvent;
    import org.glassfish.jersey.server.monitoring.RequestEventListener;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    public class EndpointLoggingListener implements ApplicationEventListener {
    
        private static final TypeResolver TYPE_RESOLVER = new TypeResolver();
    
        private final String applicationPath;
    
        private boolean withOptions = false;
        private boolean withWadl = false;
    
        public EndpointLoggingListener(String applicationPath) {
            this.applicationPath = applicationPath;
        }
    
        @Override
        public void onEvent(ApplicationEvent event) {
            if (event.getType() == ApplicationEvent.Type.INITIALIZATION_APP_FINISHED) {
                final ResourceModel resourceModel = event.getResourceModel();
                final ResourceLogDetails logDetails = new ResourceLogDetails();
                resourceModel.getResources().stream().forEach((resource) -> {
                    logDetails.addEndpointLogLines(getLinesFromResource(resource));
                });
                logDetails.log();
            }
        }
    
        @Override
        public RequestEventListener onRequest(RequestEvent requestEvent) {
            return null;
        }
    
        public EndpointLoggingListener withOptions() {
            this.withOptions = true;
            return this;
        }
    
        public EndpointLoggingListener withWadl() {
            this.withWadl = true;
            return this;
        }
    
        private Set<EndpointLogLine> getLinesFromResource(Resource resource) {
            Set<EndpointLogLine> logLines = new HashSet<>();
            populate(this.applicationPath, false, resource, logLines);
            return logLines;
        }
    
        private void populate(String basePath, Class<?> klass, boolean isLocator,
                Set<EndpointLogLine> endpointLogLines) {
            populate(basePath, isLocator, Resource.from(klass), endpointLogLines);
        }
    
        private void populate(String basePath, boolean isLocator, Resource resource,
                Set<EndpointLogLine> endpointLogLines) {
            if (!isLocator) {
                basePath = normalizePath(basePath, resource.getPath());
            }
    
            for (ResourceMethod method : resource.getResourceMethods()) {
                if (!withOptions && method.getHttpMethod().equalsIgnoreCase("OPTIONS")) {
                    continue;
                }
                if (!withWadl && basePath.contains(".wadl")) {
                    continue;
                }
                endpointLogLines.add(new EndpointLogLine(method.getHttpMethod(), basePath, null));
            }
    
            for (Resource childResource : resource.getChildResources()) {
                for (ResourceMethod method : childResource.getAllMethods()) {
                    if (method.getType() == ResourceMethod.JaxrsType.RESOURCE_METHOD) {
                        final String path = normalizePath(basePath, childResource.getPath());
                        if (!withOptions && method.getHttpMethod().equalsIgnoreCase("OPTIONS")) {
                            continue;
                        }
                        if (!withWadl && path.contains(".wadl")) {
                            continue;
                        }
                        endpointLogLines.add(new EndpointLogLine(method.getHttpMethod(), path, null));
                    } else if (method.getType() == ResourceMethod.JaxrsType.SUB_RESOURCE_LOCATOR) {
                        final String path = normalizePath(basePath, childResource.getPath());
                        final ResolvedType responseType = TYPE_RESOLVER
                                .resolve(method.getInvocable().getResponseType());
                        final Class<?> erasedType = !responseType.getTypeBindings().isEmpty()
                                ? responseType.getTypeBindings().getBoundType(0).getErasedType()
                                : responseType.getErasedType();
                        populate(path, erasedType, true, endpointLogLines);
                    }
                }
            }
        }
    
        private static String normalizePath(String basePath, String path) {
            if (path == null) {
                return basePath;
            }
            if (basePath.endsWith("/")) {
                return path.startsWith("/") ? basePath + path.substring(1) : basePath + path;
            }
            return path.startsWith("/") ? basePath + path : basePath + "/" + path;
        }
    
        private static class ResourceLogDetails {
    
            private static final Logger logger = LoggerFactory.getLogger(ResourceLogDetails.class);
    
            private static final Comparator<EndpointLogLine> COMPARATOR
                    = Comparator.comparing((EndpointLogLine e) -> e.path)
                    .thenComparing((EndpointLogLine e) -> e.httpMethod);
    
            private final Set<EndpointLogLine> logLines = new TreeSet<>(COMPARATOR);
    
            private void log() {
                StringBuilder sb = new StringBuilder("\nAll endpoints for Jersey application\n");
                logLines.stream().forEach((line) -> {
                    sb.append(line).append("\n");
                });
                logger.info(sb.toString());
            }
    
            private void addEndpointLogLines(Set<EndpointLogLine> logLines) {
                this.logLines.addAll(logLines);
            }
        }
    
        private static class EndpointLogLine {
    
            private static final String DEFAULT_FORMAT = "   %-7s %s";
            final String httpMethod;
            final String path;
            final String format;
    
            private EndpointLogLine(String httpMethod, String path, String format) {
                this.httpMethod = httpMethod;
                this.path = path;
                this.format = format == null ? DEFAULT_FORMAT : format;
            }
    
            @Override
            public String toString() {
                return String.format(format, httpMethod, path);
            }
        }
    }
    

    Then you just need to register the listener with Jersey. You can get the application path from the JerseyProperties. You will need to have set it in the Spring Boot application.properties under the property spring.jersey.applicationPath. This will be the root path, just as if you were to use @ApplicationPath on your ResourceConfig subclass

    @Bean
    public ResourceConfig getResourceConfig(JerseyProperties jerseyProperties) {
        return new JerseyConfig(jerseyProperties);
    }
    ...
    public class JerseyConfig extends ResourceConfig {
    
        public JerseyConfig(JerseyProperties jerseyProperties) {
            register(HelloResource.class);
            register(new EndpointLoggingListener(jerseyProperties.getApplicationPath()));
        }
    }
    

    One thing to note, is that the load-on-startup is not set by default on the Jersey servlet. What this means is that that Jersey won't load on startup until the first request. So you will not see the listener triggered until the first request. I have opened an issue to possible get a configuration property, but in the meantime, you have a couple options:

    1. Set up Jersey as filter, instead of a servlet. The filter will be loaded on start up. Using Jersey as a filter, for the most post, really doesn't behave any differently. To configure this you just need to add a Spring Boot property in the application.properties

      spring.jersey.type=filter
      
    2. The other option is to override the Jersey ServletRegistrationBean and set its loadOnStartup property. Here is an example configuration. Some of the implementation has been taken straight from the JerseyAutoConfiguration

      @SpringBootApplication
      public class JerseyApplication {
      
          public static void main(String[] args) {
              SpringApplication.run(JerseyApplication.class, args);
          }
      
          @Bean
          public ResourceConfig getResourceConfig(JerseyProperties jerseyProperties) {
              return new JerseyConfig(jerseyProperties);
          }
      
          @Bean
          public ServletRegistrationBean jerseyServletRegistration(
              JerseyProperties jerseyProperties, ResourceConfig config) {
              ServletRegistrationBean registration = new ServletRegistrationBean(
                      new ServletContainer(config), 
                      parseApplicationPath(jerseyProperties.getApplicationPath())
              );
              addInitParameters(registration, jerseyProperties);
              registration.setName(JerseyConfig.class.getName());
              registration.setLoadOnStartup(1);
              return registration;
          }
      
          private static String parseApplicationPath(String applicationPath) {
              if (!applicationPath.startsWith("/")) {
                  applicationPath = "/" + applicationPath;
              }
              return applicationPath.equals("/") ? "/*" : applicationPath + "/*";
          }
      
          private void addInitParameters(RegistrationBean registration, JerseyProperties jersey) {
              for (Entry<String, String> entry : jersey.getInit().entrySet()) {
                  registration.addInitParameter(entry.getKey(), entry.getValue());
              }
          }
      }
      

    UPDATE

    So it looks like Spring Boot is going to add the load-on-startup property, so we don't have to override the Jersey ServletRegistrationBean. Will be added in Boot 1.4.0