I ended up going in a similar manner to what is described here but I will try to show an example.
I should mention, anytime I called afterPropertiesSet, before I wrote all this code, it would duplicate registered endpoints and cause exceptions to be thrown. It looks like @Dominic had the same issue and simply unregisters the existing before calling afterPropertiesSet
I am working in a large project with over 1000 endpoints mapped with spring annotations. The requirements for me were to have Spring handle my dynamic endpoints exactly the same as a fully annotated and registered by Spring RestController.
I wanted to see if it was possible to populate a custom class implementing ConfigurationProperties with application.yml or application.properties (or another propertySource) supplied data and to then use that mapping to define my endpoints at runtime.
It took some time but this "does" work. I cannot say it is the prettiest code I have written, also, we did not end up using it in the end.
ExampleService.java is simply an @Service style bean with the business logic to retrieve data from somewhere, like a database or rabbitmq or a file or some other sort of storage means. In my example below it simply has a String variable named "name" to explain how the @ConfigurationProperties work. I did not have to use the Service class, it could just be a regular POJO that gets mapped and used to generate the service and controller beans.
Define the custom ConfigurationProperties:
@ConfigurationProperties(prefix = ExampleConfigurationProperties.PREFIX)
@ConstructorBinding
public class ExampleConfigurationProperties {
/**
* Properties prefix.
*/
public static final String PREFIX = "custom.endpoints";
private final Set<ExampleService> exampleService;
public ExampleConfigurationProperties(@Nullable final Set<ExampleService> services) {
this.exampleService = services;
}
public Set<ExampleService> getExampleServices() {
return exampleService;
}
}
In your application.yml or properties(properties is similar but is written different), you will want something like:
custom:
endpoints:
services[0]:
name: SomeName
# also some other properties in the ExampleService to map
services[1]:
name: SomeOtherName
# also some other properties in the ExampleService to map
Configuration class, The important part in the constructor is the
@Qualifier
, in this application there were 4 beans of type RequestMappingHandlerMapping defined and I needed to specify the one I wanted to wire my endpoints into. I ended up wiring up a List and finding them in debug mode to identify the one I wanted.
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ExampleConfigurationProperties.class)
public class ExampleSpringConfig implements ApplicationContextAware {
private final ExampleConfigurationProperties properties;
public ExampleSpringConfig (
final ExampleConfigurationProperties properties,
@Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping) {
this.properties = Objects.requireNonNull(properties);
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
//there is probably a better way of getting this beanFactory but I have no reason to further research this as we did not end up going this route.
ConfigurableApplicationContext context = (ConfigurableApplicationContext) applicationContext;
DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory();
for (ExampleService svc : properties.getExampleService) {
// good spot to "wire" in any required dependencies/beans for this "Properties" mapped list of objects
// svc.setRandomInternalProperty(<someValue>);
// give your new "Properties" initiated object a unique name and make it a bean in the current context
beanFactory.registerSingleton(svc.getClass().getSimpleName() + "_" + svc.getName(), svc);
// this will map the controllers methods to the "Properties" mapped name paramenter appended to "Endpoint"
ExampleControllerRest controller =
ExampleControllerRest.createController(handlerMapping, "/" + svc.getName() + "Endpoint", svc);
// once the controller has all of its methods I want exposed mapped, register the controller as a Spring Bean for use elsewhere
beanFactory.registerSingleton(controller.getClass().getSimpleName() + "_" + svc.getName(), controller);
}
}
}
Then the Controller class:
public class ExampleController {
private final ExampleService exampleService;
public ExampleController(final ExampleService exampleService) {
this.exampleService = exampleService;
}
public static ExampleController createController(RequestMappingHandlerMapping handlerMapping, String path, ExampleService exampleService) {
try (DynamicType.Unloaded<ExampleController> temp = new ByteBuddy()
.subclass(ExampleController.class)
.annotateType(AnnotationDescription.Builder
.ofType(RequestMapping.class)
.defineArray("value", new String[]{path})
.build())
.annotateType(AnnotationDescription.Builder
.ofType(RestController.class)
.build())
.make();
) {
ExampleController controller = temp.load(ExampleController.class.getClassLoader())
.getLoaded()
.getConstructor(ExampleService.class)
.newInstance(baseUnitAssignmentsDTOService);
RequestMappingInfo.BuilderConfiguration builderConfiguration = new RequestMappingInfo.BuilderConfiguration();
// this was important to get a response back that replicated what Spring autowiring does with custom
// response types, otherwise an error is thrown about an empty response body
// if you are going to use ResponseEntity for response object type wrapping, this might not be needed
builderConfiguration.setContentNegotiationManager(handlerMapping.getContentNegotiationManager());
// the pathMatcher was added to have these endpoints look exactly the same as the ones that Spring autowires, I am not sure if this is needed
AntPathMatcher pathMatcher = (AntPathMatcher) handlerMapping.getPathMatcher();
pathMatcher.match(path, path);
builderConfiguration.setPathMatcher(pathMatcher);
// handler endpoint mappings, these are all of the endpoints I want defined for each element in the custom "Properties" set of services
handlerMapping.registerMapping(RequestMappingInfo
.paths(path + "/getAll")
.methods(RequestMethod.GET)
.options(builderConfiguration)
.produces(MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE)
.build(), controller, controller.getClass().getMethod("getAll"));
// I have redacted a bunch of endpoints but the essential part is to define all endpoints similar to the "getAll" above
handlerMapping.registerMapping(RequestMappingInfo
.paths(path + "/insert")
.methods(RequestMethod.POST)
.options(builderConfiguration)
.consumes(MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE)
.produces(MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE)
.build(), controller, controller.getClass().getMethod("insert", PickListOptionDTO.class));
// just remember to define all method body param types for reflection to find the corret method
return controller;
} catch (IOException | InvocationTargetException | InstantiationException | IllegalAccessException |
NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
public List<PickListOptionDTO> getAll() {
return getService().getAllUsers();
}
public PickListOptionDTO insert(@RequestBody PickListOptionDTO dto) {
return getService().insertUnit(dto);
}
public ExampleService getService() {
return exampleService;
}
}